Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a3560a19f | ||
|
|
3b525300e0 | ||
|
|
08ff49d3f5 | ||
|
|
f5c4271a07 | ||
|
|
74d41b43b6 | ||
|
|
3408bba303 | ||
|
|
5e00e998a8 | ||
|
|
3847f0cda0 | ||
|
|
1ebcd017bd | ||
|
|
9013a7e312 | ||
|
|
afefbd953f | ||
|
|
535b141b23 | ||
|
|
b21e44b65f | ||
|
|
b42be379e3 | ||
|
|
b2f0a3bea3 | ||
|
|
df3745d185 | ||
|
|
f85bb3f9b2 | ||
|
|
566f3e3c32 | ||
|
|
58eb91fb23 | ||
|
|
36267717ac | ||
|
|
5e323f1f8f | ||
|
|
c0efc9d5c1 | ||
|
|
61188ab8e2 | ||
|
|
ae209d37c1 | ||
|
|
a5b0efba75 | ||
|
|
5adb64e40e | ||
|
|
41fea1028d | ||
|
|
5a90a4331b | ||
|
|
881f3b1a34 | ||
|
|
8be5865b76 | ||
|
|
685d1cb41a | ||
|
|
14fe1e3ecb | ||
|
|
636f4d7037 | ||
|
|
c92ad2f601 | ||
|
|
602ca92476 | ||
|
|
3203ed7a19 | ||
|
|
846d8246a3 | ||
|
|
26a04b22d3 | ||
|
|
f5f55ffc2e | ||
|
|
0792ce1415 | ||
|
|
eca23a2691 | ||
|
|
aa1781577b | ||
|
|
409d293faa | ||
|
|
39fea86f13 | ||
|
|
ce5d1d0e5a | ||
|
|
7ac29366ae | ||
|
|
4860581525 | ||
|
|
d9156349e1 | ||
|
|
983b0b2f1d | ||
|
|
a552c14cbd | ||
|
|
3f5787ceb1 | ||
|
|
e4ec2363d0 | ||
|
|
84b71910ee | ||
|
|
371217832b | ||
|
|
afb514b472 | ||
|
|
e14dc22bba | ||
|
|
6b7c12c23c | ||
|
|
222b3869dd | ||
|
|
56af2d3840 | ||
|
|
1695470089 | ||
|
|
d4b5f799cb |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -8,6 +8,7 @@ pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
@@ -41,6 +42,15 @@ coverage
|
||||
# Codex
|
||||
/.codex/
|
||||
|
||||
# Qoder
|
||||
.qoder
|
||||
|
||||
# Workbuddy
|
||||
.workbuddy
|
||||
|
||||
# Codebuddy
|
||||
.codebuddy
|
||||
|
||||
# AI / Superpowers generated docs (local only)
|
||||
/docs/superpowers/
|
||||
|
||||
|
||||
6
App.tsx
6
App.tsx
@@ -229,6 +229,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
updateSessionFontSize,
|
||||
clearSessionFontSizeOverride,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
@@ -725,7 +727,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
);
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings, confirmIfBusyLocalTerminal]);
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, isQuickSwitcherOpen, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings, confirmIfBusyLocalTerminal]);
|
||||
|
||||
const handleWindowCommandCloseRequest = useCallback(async () => {
|
||||
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
|
||||
@@ -985,7 +987,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
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, 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 }} />
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
|
||||
import { executeHotkeyActionImpl, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
|
||||
import { matchesKeyBinding } from '../domain/models.ts';
|
||||
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
|
||||
|
||||
class FakeInputHTMLElement {
|
||||
tagName = 'INPUT';
|
||||
isContentEditable = false;
|
||||
|
||||
closest(): FakeInputHTMLElement | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeHTMLElement {
|
||||
tagName = 'TEXTAREA';
|
||||
isContentEditable = false;
|
||||
@@ -68,3 +77,95 @@ test('global hotkey handler lets terminal font size shortcuts reach xterm', () =
|
||||
assert.equal(prevented, false);
|
||||
assert.equal(stopped, false);
|
||||
});
|
||||
|
||||
test('global hotkey handler routes quick switch through focused search inputs', () => {
|
||||
const target = new FakeInputHTMLElement();
|
||||
const handledActions: string[] = [];
|
||||
const event = {
|
||||
key: 'j',
|
||||
code: 'KeyJ',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
handleGlobalHotkeyKeyDownImpl(
|
||||
() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: 'Ctrl + W',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
handledActions.push(action);
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}),
|
||||
event,
|
||||
);
|
||||
|
||||
assert.deepEqual(handledActions, ['quickSwitch']);
|
||||
});
|
||||
|
||||
test('quick switch hotkey toggles the quick switcher open state', () => {
|
||||
let isQuickSwitcherOpen = false;
|
||||
const setIsQuickSwitcherOpen = (next: boolean) => {
|
||||
isQuickSwitcherOpen = next;
|
||||
};
|
||||
const noop = () => {};
|
||||
const baseCtx = {
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => 'vault' },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isQuickSwitcherOpen,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: [],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: noop,
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
const event = {
|
||||
key: 'j',
|
||||
code: 'KeyJ',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
} as KeyboardEvent;
|
||||
|
||||
executeHotkeyActionImpl(() => baseCtx, 'quickSwitch', event);
|
||||
assert.equal(isQuickSwitcherOpen, true);
|
||||
|
||||
executeHotkeyActionImpl(() => ({ ...baseCtx, isQuickSwitcherOpen: true }), 'quickSwitch', event);
|
||||
assert.equal(isQuickSwitcherOpen, false);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Host, HostProtocol } from '../../types';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import { getEffectiveHostDistro } from '../../domain/host';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
|
||||
type AppContextGetter = () => Record<string, any>;
|
||||
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
|
||||
@@ -131,7 +132,11 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
|
||||
|
||||
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
|
||||
const quickSwitchBinding = keyBindings.find((binding) => binding.action === 'quickSwitch');
|
||||
const quickSwitchKeyStr = quickSwitchBinding ? (isMac ? quickSwitchBinding.mac : quickSwitchBinding.pc) : null;
|
||||
const isQuickSwitchHotkey = quickSwitchKeyStr ? matchesKeyBinding(e, quickSwitchKeyStr, isMac) : false;
|
||||
|
||||
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape' && !isQuickSwitchHotkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -435,7 +440,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
|
||||
}
|
||||
|
||||
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
{
|
||||
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces + editor tabs.
|
||||
// Hiding the SFTP tab must also remove it from keyboard cycling so nextTab
|
||||
@@ -444,13 +449,19 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
const allTabs = settings.showSftpTab
|
||||
? ['vault', 'sftp', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))]
|
||||
: ['vault', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))];
|
||||
const numberShortcutTabs = buildNumberShortcutTabTargets({
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
shellOnlyTabNumberShortcuts: settings.shellOnlyTabNumberShortcuts ?? false,
|
||||
orderedTabs,
|
||||
editorTabIds: editorTabs.map((t) => toEditorTabId(t.id)),
|
||||
});
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
// Get the number key pressed (1-9)
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= 9) {
|
||||
if (num <= allTabs.length) {
|
||||
setActiveTabId(allTabs[num - 1]);
|
||||
if (num <= numberShortcutTabs.length) {
|
||||
setActiveTabId(numberShortcutTabs[num - 1]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -553,6 +564,8 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
}
|
||||
break;
|
||||
case 'quickSwitch':
|
||||
setIsQuickSwitcherOpen(!isQuickSwitcherOpen);
|
||||
break;
|
||||
case 'commandPalette':
|
||||
setIsQuickSwitcherOpen(true);
|
||||
break;
|
||||
|
||||
@@ -14,7 +14,6 @@ Object.defineProperty(globalThis, 'localStorage', {
|
||||
|
||||
const {
|
||||
getAppHostTreeLayerStyle,
|
||||
shouldAutoOpenHostTreeOnSurfaceChange,
|
||||
} = await import('./AppHostTreeLayer');
|
||||
const hostTreeLayerSource = readFileSync(new URL('./AppHostTreeLayer.tsx', import.meta.url), 'utf8');
|
||||
|
||||
@@ -34,28 +33,9 @@ test('shared host tree layer is hidden behind root pages', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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('shared host tree does not force open when entering a work tab surface', () => {
|
||||
assert.doesNotMatch(hostTreeLayerSource, /setIsOpen\(true\)/);
|
||||
assert.doesNotMatch(hostTreeLayerSource, /shouldAutoOpenHostTreeOnSurfaceChange/);
|
||||
});
|
||||
|
||||
test('host tree layer hides immediately when leaving work tab surfaces', () => {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import React, { useMemo } 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 type { GroupConfig, Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
import {
|
||||
isHostTreeWorkTabSurface,
|
||||
resolveWorkTabActiveHostId,
|
||||
@@ -16,6 +14,7 @@ interface AppHostTreeLayerProps {
|
||||
enabled: boolean;
|
||||
hosts: Host[];
|
||||
customGroups: string[];
|
||||
groupConfigs: GroupConfig[];
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
editorTabs: readonly EditorTab[];
|
||||
@@ -34,22 +33,11 @@ export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProp
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
groupConfigs,
|
||||
sessions,
|
||||
workspaces,
|
||||
editorTabs,
|
||||
@@ -60,8 +48,6 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
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]);
|
||||
@@ -73,28 +59,6 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
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,
|
||||
@@ -114,6 +78,7 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
surfaceVisible={surfaceVisible}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
resolvedPreviewTheme={resolvedPreviewTheme}
|
||||
activeHostId={activeHostId}
|
||||
onConnect={onConnect}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
|
||||
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
|
||||
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId,
|
||||
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, updateSessionFontSize, clearSessionFontSizeOverride,
|
||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId,
|
||||
@@ -146,6 +146,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
enabled={settings.showHostTreeSidebar}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
editorTabs={editorTabs}
|
||||
@@ -254,6 +255,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onUpdateTerminalThemeId={setTerminalThemeId}
|
||||
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
|
||||
onUpdateTerminalFontSize={setTerminalFontSize}
|
||||
onUpdateSessionFontSize={updateSessionFontSize}
|
||||
onClearSessionFontSizeOverride={clearSessionFontSizeOverride}
|
||||
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
|
||||
onCloseSession={closeSession}
|
||||
onUpdateSessionStatus={handleSessionStatusChange}
|
||||
@@ -263,6 +266,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onCommandExecuted={(command, hostId, hostLabel, sessionId) => {
|
||||
addShellHistoryEntry({ command, hostId, hostLabel, sessionId });
|
||||
}}
|
||||
shellHistory={shellHistory}
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
@@ -280,6 +284,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
updateHosts={updateHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
updateSnippetPackages={updateSnippetPackages}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
|
||||
40
application/app/tabShortcutTargets.test.ts
Normal file
40
application/app/tabShortcutTargets.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets.ts';
|
||||
|
||||
test('number shortcut tabs include vault and sftp by default', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: ['editor:file-1'],
|
||||
}),
|
||||
['vault', 'sftp', 'session-1', 'workspace-1', 'editor:file-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('number shortcut tabs skip vault and sftp when shell-only mode is enabled', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: true,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: ['editor:file-1'],
|
||||
}),
|
||||
['session-1', 'workspace-1', 'editor:file-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('hidden sftp tab is omitted from default number shortcut targets', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: false,
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
orderedTabs: ['session-1'],
|
||||
editorTabIds: [],
|
||||
}),
|
||||
['vault', 'session-1'],
|
||||
);
|
||||
});
|
||||
14
application/app/tabShortcutTargets.ts
Normal file
14
application/app/tabShortcutTargets.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/** Tab ids targeted by Cmd/Ctrl+[1...9] number shortcuts. */
|
||||
export function buildNumberShortcutTabTargets(params: {
|
||||
showSftpTab: boolean;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
orderedTabs: readonly string[];
|
||||
editorTabIds: readonly string[];
|
||||
}): string[] {
|
||||
const workTabs = [...params.orderedTabs, ...params.editorTabIds];
|
||||
if (params.shellOnlyTabNumberShortcuts) {
|
||||
return workTabs;
|
||||
}
|
||||
const pinnedTabs = params.showSftpTab ? ['vault', 'sftp'] : ['vault'];
|
||||
return [...pinnedTabs, ...workTabs];
|
||||
}
|
||||
@@ -14,11 +14,19 @@ const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
const interpolate = (template: string, values?: InterpolationValues): string => {
|
||||
if (!values) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (_match, key: string) => {
|
||||
const replaceDoubleBraceToken = (match: string, key: string) => {
|
||||
const v = values[key];
|
||||
if (v === null || v === undefined) return match;
|
||||
return String(v);
|
||||
};
|
||||
const replaceSingleBraceToken = (_match: string, key: string) => {
|
||||
const v = values[key];
|
||||
if (v === null || v === undefined) return '';
|
||||
return String(v);
|
||||
});
|
||||
};
|
||||
return template
|
||||
.replace(/\{\{(\w+)\}\}/g, replaceDoubleBraceToken)
|
||||
.replace(/\{(\w+)\}/g, replaceSingleBraceToken);
|
||||
};
|
||||
|
||||
const resolveMessage = (resolvedLocale: string, key: string): string | undefined => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { enCoreMessages } from './en/core';
|
||||
import { enVaultMessages } from './en/vault';
|
||||
import { enTerminalMessages } from './en/terminal';
|
||||
import { enAiMessages } from './en/ai';
|
||||
import { enSystemManagerMessages } from './en/systemManager';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
@@ -11,6 +12,7 @@ const en: Messages = {
|
||||
...enVaultMessages,
|
||||
...enTerminalMessages,
|
||||
...enAiMessages,
|
||||
...enSystemManagerMessages,
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -106,6 +106,54 @@ export const enAiMessages: Messages = {
|
||||
'ai.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
|
||||
'ai.copilot.check': 'Check',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
'ai.cursor.description': 'Uses the Cursor SDK.',
|
||||
'ai.cursor.detecting': 'Detecting...',
|
||||
'ai.cursor.detected': 'Available',
|
||||
'ai.cursor.notFound': 'Unavailable',
|
||||
'ai.cursor.path': 'Runtime:',
|
||||
'ai.cursor.notFoundHint': 'Enter an API key to enable Cursor.',
|
||||
'ai.cursor.notInstalledHint': 'Cursor SDK was not detected.',
|
||||
'ai.cursor.installStatus': 'Cursor SDK',
|
||||
'ai.cursor.installed': 'Detected',
|
||||
'ai.cursor.notInstalled': 'Not detected',
|
||||
'ai.cursor.apiKeyStatus': 'API Key',
|
||||
'ai.cursor.apiKeyConfigured': 'Configured',
|
||||
'ai.cursor.apiKeyMissing': 'Missing',
|
||||
'ai.cursor.apiKeyFromEnv': 'From environment',
|
||||
'ai.cursor.apiKey': 'API Key',
|
||||
'ai.cursor.apiKeyPlaceholder': 'Enter Cursor API key',
|
||||
'ai.cursor.apiKeyPlaceholder.env': 'Using CURSOR_API_KEY; enter a key to override',
|
||||
'ai.cursor.apiKeyEnvHint': 'Cursor can use CURSOR_API_KEY from your shell. Save a key here only if you want Netcatty to override it.',
|
||||
'ai.cursor.apiKeyOverrideHint': 'Netcatty will use the saved key here before CURSOR_API_KEY.',
|
||||
'ai.cursor.saveApiKey': 'Save',
|
||||
'ai.cursor.saved': 'Saved',
|
||||
'ai.cursor.showApiKey': 'Show API key',
|
||||
'ai.cursor.hideApiKey': 'Hide API key',
|
||||
'ai.cursor.customPathPlaceholder': 'e.g. /usr/local/bin/cursor',
|
||||
'ai.cursor.check': 'Check',
|
||||
|
||||
// AI CodeBuddy Code
|
||||
'ai.codebuddy.title': 'CodeBuddy Code',
|
||||
'ai.codebuddy.description': 'Uses CodeBuddy Code via the official Agent SDK (`@tencent-ai/agent-sdk`). Once detected, it can be selected as an external coding agent.',
|
||||
'ai.codebuddy.detecting': 'Detecting...',
|
||||
'ai.codebuddy.detected': 'Detected',
|
||||
'ai.codebuddy.notFound': 'Not found',
|
||||
'ai.codebuddy.path': 'Path:',
|
||||
'ai.codebuddy.notFoundHint': 'Could not find codebuddy in PATH. Install it or specify the executable path below.',
|
||||
'ai.codebuddy.customPathPlaceholder': 'e.g. /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': 'Check',
|
||||
'ai.codebuddy.configSection': 'Authentication & config (optional)',
|
||||
'ai.codebuddy.internetEnv': 'Internet Environment',
|
||||
'ai.codebuddy.internetEnv.default': 'Default (overseas)',
|
||||
'ai.codebuddy.internetEnv.internal': 'Internal',
|
||||
'ai.codebuddy.internetEnv.ioa': 'IOA',
|
||||
'ai.codebuddy.internetEnv.hint': 'Sets CODEBUDDY_INTERNET_ENVIRONMENT — choose Internal or IOA for restricted network environments.',
|
||||
'ai.codebuddy.envVars': 'Environment variables',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'One KEY=VALUE per line, passed to the CodeBuddy agent. Set CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN here for authentication. Stored locally in plaintext.',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Default Agent',
|
||||
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
|
||||
@@ -127,6 +175,29 @@ export const enAiMessages: Messages = {
|
||||
'ai.userSkills.status.ready': 'Ready',
|
||||
'ai.userSkills.status.warning': 'Warning',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': 'Quick Messages',
|
||||
'ai.quickMessages.description': 'Create reusable prompts you can insert from the AI chat with / or the quick-message button. Unlike user skills, quick messages fill the composer with text.',
|
||||
'ai.quickMessages.add': 'Add Quick Message',
|
||||
'ai.quickMessages.createTitle': 'New Quick Message',
|
||||
'ai.quickMessages.editTitle': 'Edit Quick Message',
|
||||
'ai.quickMessages.name': 'Name',
|
||||
'ai.quickMessages.name.placeholder': 'e.g. Check disk space',
|
||||
'ai.quickMessages.slug': 'Command',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': 'Description (optional)',
|
||||
'ai.quickMessages.descriptionField.placeholder': 'Short hint about what this prompt does',
|
||||
'ai.quickMessages.content': 'Message content',
|
||||
'ai.quickMessages.content.placeholder': 'Full prompt text to insert when selected...',
|
||||
'ai.quickMessages.empty': 'No quick messages yet. Add a few prompts you use often.',
|
||||
'ai.quickMessages.confirmDelete': 'Delete quick message "{name}"?',
|
||||
'ai.quickMessages.error.nameRequired': 'Name is required.',
|
||||
'ai.quickMessages.error.invalidSlug': 'Command may only contain lowercase letters, numbers, and hyphens.',
|
||||
'ai.quickMessages.error.contentRequired': 'Message content is required.',
|
||||
'ai.quickMessages.error.slugTaken': 'This command is already used by another quick message.',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': 'This command conflicts with user skill "/{slug}". Choose another.',
|
||||
'ai.quickMessages.error.maxItems': 'You can save at most {max} quick messages.',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
|
||||
'ai.chat.toolDenied': 'Action was rejected by the user.',
|
||||
@@ -175,6 +246,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.newChat': 'New Chat',
|
||||
'ai.chat.allSessions': 'All Sessions',
|
||||
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
|
||||
'ai.chat.usedTools': 'Tools used: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
|
||||
'ai.chat.noSessions': 'No previous sessions',
|
||||
'ai.chat.retryHint': 'You can retry by sending your message again.',
|
||||
@@ -185,6 +257,13 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.menuImage': 'Image',
|
||||
'ai.chat.menuMentionHost': 'Mention Host',
|
||||
'ai.chat.menuUserSkills': 'User Skills',
|
||||
'ai.chat.menuSlashCommands': 'Slash Commands',
|
||||
'ai.chat.slashCommands': 'Slash commands',
|
||||
'ai.chat.slashQuickMessages': 'Quick messages',
|
||||
'ai.chat.slashUserSkills': 'User skills',
|
||||
'ai.chat.quickMessages': 'Slash commands',
|
||||
'ai.chat.slashNoResults': 'No matching commands',
|
||||
'ai.chat.slashEmptyHint': 'Add prompts in Settings → AI → Quick Messages.',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
|
||||
@@ -228,6 +307,7 @@ export const enAiMessages: Messages = {
|
||||
'terminal.layer.switchToSplitView': 'Switch to Split View',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Scripts',
|
||||
'terminal.layer.history': 'History',
|
||||
'terminal.layer.theme': 'Theme',
|
||||
'terminal.layer.aiChat': 'AI Chat',
|
||||
'terminal.layer.movePanelLeft': 'Move panel to left',
|
||||
|
||||
@@ -42,6 +42,7 @@ export const enCoreMessages: Messages = {
|
||||
'common.more': 'More',
|
||||
'common.selectAHost': 'Select a host',
|
||||
'common.selectAHostPlaceholder': 'Select a host...',
|
||||
'sort.manual': 'Manual order',
|
||||
'sort.az': 'A-z',
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': 'Newest to oldest',
|
||||
@@ -266,9 +267,9 @@ export const enCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Dark palette',
|
||||
'settings.appearance.customCss': 'Custom CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (focus-mode terminal list), terminal-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.',
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (focus-mode terminal list), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (SFTP/Scripts/Theme/AI panel, available while open), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs, top-tabs-host-tree-toggle, top-tabs-quick-switcher-toggle.',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Border around the SFTP / side panel (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 */',
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
|
||||
'settings.appearance.language': 'Language',
|
||||
'settings.appearance.language.desc': 'Choose the UI language',
|
||||
'settings.appearance.uiFont': 'Interface Font',
|
||||
@@ -431,6 +432,15 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
|
||||
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
|
||||
'settings.terminal.section.systemManager': 'System Manager',
|
||||
'settings.terminal.systemManager.processRefreshInterval': 'Process list refresh',
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux session refresh',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'How often to refresh the tmux session list.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh the Docker container list.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker stats refresh',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'How often to refresh Docker container CPU/memory/network stats.',
|
||||
'settings.terminal.serverStats.show': 'Show Server Stats',
|
||||
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
|
||||
'settings.terminal.serverStats.refreshInterval': 'Refresh Interval',
|
||||
@@ -442,8 +452,6 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.rendering.renderer': 'Renderer',
|
||||
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.',
|
||||
'settings.terminal.rendering.auto': 'Auto',
|
||||
'settings.terminal.rendering.lineTimestamps': 'Prefix output with timestamps',
|
||||
'settings.terminal.rendering.lineTimestamps.desc': 'Insert local time before terminal output lines. The timestamp becomes part of the visible terminal content.',
|
||||
|
||||
// Settings > Terminal > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': 'Workspace Focus Indicator',
|
||||
@@ -468,6 +476,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.shortcuts.scheme.disabled': 'Disabled',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Number keys skip pinned tabs',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] switches only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
|
||||
'settings.shortcuts.section.custom': 'Custom Shortcuts',
|
||||
'settings.shortcuts.resetAll': 'Reset All',
|
||||
'settings.shortcuts.recording': 'Press keys...',
|
||||
|
||||
175
application/i18n/locales/en/systemManager.ts
Normal file
175
application/i18n/locales/en/systemManager.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enSystemManagerMessages: Messages = {
|
||||
'terminal.layer.system': 'System',
|
||||
|
||||
'systemManager.noSession': 'No active terminal session.',
|
||||
'systemManager.notConnected': 'Connect to a host to manage processes and services.',
|
||||
'systemManager.empty': 'No data available.',
|
||||
'systemManager.tabs.processes': 'Processes',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.popup.loading': 'Opening terminal…',
|
||||
'systemManager.popup.startupFailed': 'The startup command did not complete successfully. Check that the target is still available and try again.',
|
||||
|
||||
'systemManager.errors.loadProcesses': 'Failed to load processes',
|
||||
'systemManager.errors.loadTmux': 'Failed to load tmux sessions',
|
||||
'systemManager.errors.loadTmuxWindows': 'Failed to load tmux windows',
|
||||
'systemManager.errors.loadTmuxPanes': 'Failed to load tmux panes',
|
||||
'systemManager.errors.loadTmuxClients': 'Failed to load tmux clients',
|
||||
'systemManager.errors.actionFailed': 'Action failed',
|
||||
'systemManager.errors.loadDocker': 'Failed to load containers',
|
||||
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
|
||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
|
||||
|
||||
'systemManager.processes.search': 'Search processes…',
|
||||
'systemManager.processes.command': 'Command',
|
||||
'systemManager.processes.user': 'User',
|
||||
'systemManager.processes.term': 'Terminate',
|
||||
'systemManager.processes.kill': 'Kill',
|
||||
'systemManager.processes.stop': 'Stop (SIGSTOP)',
|
||||
'systemManager.processes.cont': 'Continue (SIGCONT)',
|
||||
'systemManager.processes.hup': 'Hang up (SIGHUP)',
|
||||
'systemManager.processes.renice': 'Renice',
|
||||
'systemManager.processes.renicePrompt': 'Nice value (-20 to 19)',
|
||||
'systemManager.processes.reniceInvalid': 'Nice value must be between -20 and 19',
|
||||
'systemManager.processes.confirmKill': 'Send SIGKILL to process {{pid}}?',
|
||||
'systemManager.processes.confirmSignal': 'Send SIG{{signal}} to process {{pid}}?',
|
||||
'systemManager.processes.filter.all': 'All',
|
||||
'systemManager.processes.filter.running': 'Running',
|
||||
'systemManager.processes.ppid': 'Parent PID',
|
||||
'systemManager.processes.rss': 'RSS',
|
||||
'systemManager.processes.vsz': 'Virtual size',
|
||||
'systemManager.processes.elapsed': 'Elapsed',
|
||||
'systemManager.processes.stat': 'State',
|
||||
'systemManager.processes.meta': '{{count}} process(es)',
|
||||
'systemManager.processes.state.running': 'Running',
|
||||
'systemManager.processes.state.sleeping': 'Sleeping',
|
||||
'systemManager.processes.state.stopped': 'Stopped',
|
||||
'systemManager.processes.state.zombie': 'Zombie',
|
||||
'systemManager.processes.sort.cpu': 'CPU',
|
||||
'systemManager.processes.sort.mem': 'MEM',
|
||||
'systemManager.processes.sort.pid': 'PID',
|
||||
'systemManager.processes.sort.command': 'Command',
|
||||
'systemManager.processes.sort.user': 'User',
|
||||
|
||||
'systemManager.common.dismiss': 'Dismiss',
|
||||
|
||||
'systemManager.tmux.new': 'New',
|
||||
'systemManager.tmux.search': 'Search sessions…',
|
||||
'systemManager.tmux.newSessionTitle': 'New tmux session',
|
||||
'systemManager.tmux.newSessionDesc': 'Name the session and optionally run a script on start.',
|
||||
'systemManager.tmux.newSessionTabCustom': 'Custom command',
|
||||
'systemManager.tmux.newSessionTabSnippet': 'From snippet',
|
||||
'systemManager.tmux.pickSnippet': 'From snippets',
|
||||
'systemManager.tmux.pickSnippetEmpty': 'No snippets yet — add some in the Scripts panel or Vault.',
|
||||
'systemManager.tmux.selectedSnippet': 'Using snippet: {{label}}',
|
||||
'systemManager.tmux.newSessionName': 'Session name',
|
||||
'systemManager.tmux.newSessionCommand': 'Start command',
|
||||
'systemManager.tmux.newSessionCommandPlaceholder': 'e.g. htop or npm run dev (optional)',
|
||||
'systemManager.tmux.newSessionCommandHint': 'Leave empty for a default shell session.',
|
||||
'systemManager.tmux.creating': 'Creating…',
|
||||
'systemManager.tmux.newSessionPlaceholder': 'my-session',
|
||||
'systemManager.tmux.newSessionRequired': 'Enter a session name first',
|
||||
'systemManager.tmux.empty': 'No tmux sessions',
|
||||
'systemManager.tmux.attach': 'Attach',
|
||||
'systemManager.tmux.attached': 'Attached',
|
||||
'systemManager.tmux.detached': 'Detached',
|
||||
'systemManager.tmux.windows': '{{count}} window(s)',
|
||||
'systemManager.tmux.created': 'Created',
|
||||
'systemManager.tmux.activity': 'Activity',
|
||||
'systemManager.tmux.rename': 'Rename',
|
||||
'systemManager.tmux.detach': 'Detach all',
|
||||
'systemManager.tmux.killSession': 'Kill session',
|
||||
'systemManager.tmux.killServer': 'Kill server',
|
||||
'systemManager.tmux.loadingDetails': 'Loading details…',
|
||||
'systemManager.tmux.clients': 'Attached clients',
|
||||
'systemManager.tmux.windowList': 'Windows',
|
||||
'systemManager.tmux.newWindow': 'New window',
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)',
|
||||
'systemManager.tmux.noWindows': 'No windows',
|
||||
'systemManager.tmux.unavailable': 'tmux is not available on this host',
|
||||
'systemManager.docker.unavailable': 'Docker is not available on this host',
|
||||
'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none',
|
||||
'systemManager.tmux.lastCommand': 'last command: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No panes',
|
||||
'systemManager.tmux.panes': '{{count}} pane(s)',
|
||||
'systemManager.tmux.active': 'active',
|
||||
'systemManager.tmux.unnamedWindow': 'Unnamed window',
|
||||
'systemManager.tmux.unnamedPane': 'Unnamed pane',
|
||||
'systemManager.tmux.attachWindow': 'Attach to window',
|
||||
'systemManager.tmux.selectWindow': 'Select window',
|
||||
'systemManager.tmux.killWindow': 'Kill window',
|
||||
'systemManager.tmux.killPane': 'Kill pane',
|
||||
'systemManager.tmux.splitHorizontal': 'Split horizontal',
|
||||
'systemManager.tmux.splitVertical': 'Split vertical',
|
||||
'systemManager.tmux.sendKeys': 'Send keys',
|
||||
'systemManager.tmux.sendKeysTo': 'Send keys to window {{window}} pane {{pane}}',
|
||||
'systemManager.tmux.sendKeysPlaceholder': 'Command or text…',
|
||||
'systemManager.tmux.renameSessionPrompt': 'Rename session',
|
||||
'systemManager.tmux.renameWindowPrompt': 'Rename window',
|
||||
'systemManager.tmux.windowName': 'Window name',
|
||||
'systemManager.tmux.confirmKillSession': 'Kill tmux session "{{name}}"?',
|
||||
'systemManager.tmux.confirmDetachSession': 'Detach all clients from "{{name}}"?',
|
||||
'systemManager.tmux.confirmKillWindow': 'Kill window "{{name}}"?',
|
||||
'systemManager.tmux.confirmKillPane': 'Kill pane #{{index}}?',
|
||||
'systemManager.tmux.confirmKillServer': 'Kill tmux server? All sessions will be terminated.',
|
||||
'systemManager.tmux.meta': '{{count}} session(s)',
|
||||
|
||||
'systemManager.docker.title': 'Containers',
|
||||
'systemManager.docker.subTabs.containers': 'Containers',
|
||||
'systemManager.docker.subTabs.images': 'Images',
|
||||
'systemManager.docker.empty': 'No containers found',
|
||||
'systemManager.docker.imagesEmpty': 'No images found',
|
||||
'systemManager.docker.search': 'Search containers…',
|
||||
'systemManager.docker.searchImages': 'Search images…',
|
||||
'systemManager.docker.filter.all': 'All',
|
||||
'systemManager.docker.filter.running': 'Running',
|
||||
'systemManager.docker.filter.stopped': 'Stopped',
|
||||
'systemManager.docker.filter.paused': 'Paused',
|
||||
'systemManager.docker.shell': 'Shell',
|
||||
'systemManager.docker.logs': 'Logs',
|
||||
'systemManager.docker.details': 'Details',
|
||||
'systemManager.docker.inspect': 'Inspect',
|
||||
'systemManager.docker.imageInspect': 'Image inspect',
|
||||
'systemManager.docker.confirmRemove': 'Remove this container?',
|
||||
'systemManager.docker.confirmKill': 'Force kill this container?',
|
||||
'systemManager.docker.confirmRemoveImage': 'Remove image "{{name}}"?',
|
||||
'systemManager.docker.confirmPrune': 'Remove dangling images?',
|
||||
'systemManager.docker.confirmPruneAll': 'Remove all unused images?',
|
||||
'systemManager.docker.pause': 'Pause',
|
||||
'systemManager.docker.unpause': 'Unpause',
|
||||
'systemManager.docker.restart': 'Restart',
|
||||
'systemManager.docker.kill': 'Kill',
|
||||
'systemManager.docker.renamePrompt': 'Container name',
|
||||
'systemManager.docker.prune': 'Prune',
|
||||
'systemManager.docker.pruneAll': 'Prune all',
|
||||
'systemManager.docker.tag': 'Tag',
|
||||
'systemManager.docker.tagRepoPrompt': 'Repository name',
|
||||
'systemManager.docker.tagNamePrompt': 'Tag name',
|
||||
'systemManager.docker.meta': '{{count}} container(s)',
|
||||
'systemManager.docker.imagesMeta': '{{count}} image(s)',
|
||||
'systemManager.docker.start': 'Start',
|
||||
'systemManager.docker.stop': 'Stop',
|
||||
|
||||
'systemManager.inspect.status': 'Status',
|
||||
'systemManager.inspect.image': 'Image',
|
||||
'systemManager.inspect.created': 'Created',
|
||||
'systemManager.inspect.started': 'Started',
|
||||
'systemManager.inspect.restartPolicy': 'Restart policy',
|
||||
'systemManager.inspect.command': 'Command',
|
||||
'systemManager.inspect.ports': 'Ports',
|
||||
'systemManager.inspect.networks': 'Networks',
|
||||
'systemManager.inspect.mounts': 'Mounts',
|
||||
'systemManager.inspect.env': 'Environment',
|
||||
'systemManager.inspect.labels': 'Labels',
|
||||
'systemManager.inspect.tags': 'Tags',
|
||||
'systemManager.inspect.digests': 'Digests',
|
||||
'systemManager.inspect.size': 'Size',
|
||||
'systemManager.inspect.platform': 'Platform',
|
||||
'systemManager.inspect.workdir': 'Working dir',
|
||||
'systemManager.inspect.exposedPorts': 'Exposed ports',
|
||||
'systemManager.inspect.showRaw': 'JSON',
|
||||
'systemManager.inspect.hideRaw': 'Hide JSON',
|
||||
};
|
||||
@@ -5,9 +5,26 @@ export const enTerminalMessages: Messages = {
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Available after connect',
|
||||
'terminal.toolbar.sendYmodem': 'Send with YMODEM',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'More actions',
|
||||
'terminal.toolbar.scripts': 'Scripts',
|
||||
'terminal.toolbar.history': 'Command history',
|
||||
'history.scope.label': 'History scope',
|
||||
'history.tab.host': 'Host',
|
||||
'history.tab.global': 'Global',
|
||||
'history.searchPlaceholder': 'Search history...',
|
||||
'history.loading': 'Loading remote history...',
|
||||
'history.meta.count': '{count} commands',
|
||||
'history.empty.noSession': 'Open a remote session to view its command history.',
|
||||
'history.empty.unsupportedProtocol': 'Command history is only available for SSH/Mosh/ET sessions.',
|
||||
'history.empty.noHistory': 'No command history found on this host.',
|
||||
'history.empty.noGlobalHistory': 'No global command history yet. Commands you run will appear here.',
|
||||
'history.action.refresh': 'Refresh',
|
||||
'history.action.retry': 'Retry',
|
||||
'history.action.paste': 'Paste to terminal',
|
||||
'history.action.run': 'Run in terminal',
|
||||
'history.action.saveAsSnippet': 'Save as snippet',
|
||||
'terminal.toolbar.library': 'Library',
|
||||
'terminal.toolbar.noSnippets': 'No snippets available',
|
||||
'terminal.toolbar.terminalSettings': 'Terminal settings',
|
||||
@@ -83,10 +100,17 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.menu.pasteSelection': 'Paste Selection',
|
||||
'terminal.menu.selectAll': 'Select All',
|
||||
'terminal.menu.reconnect': 'Reconnect',
|
||||
'terminal.menu.sendYmodem': 'Send with YMODEM',
|
||||
'terminal.menu.splitHorizontal': 'Split Horizontal',
|
||||
'terminal.menu.splitVertical': 'Split Vertical',
|
||||
'terminal.menu.clearBuffer': 'Clear Buffer',
|
||||
'terminal.menu.closeTerminal': 'Close terminal',
|
||||
'terminal.ymodem.selectFile': 'Select file to send',
|
||||
'terminal.ymodem.allFiles': 'All files',
|
||||
'terminal.ymodem.started': 'YMODEM sending {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM sent {fileName}',
|
||||
'terminal.ymodem.failed': 'YMODEM send failed',
|
||||
'terminal.ymodem.unavailable': 'YMODEM is unavailable',
|
||||
'terminal.selection.addToAI': 'Add to Conversation',
|
||||
'terminal.selection.addToAIDesc': 'Attach selected terminal output to the AI draft',
|
||||
'terminal.auth.password': 'Password',
|
||||
|
||||
@@ -530,6 +530,8 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH Algorithms',
|
||||
'hostDetails.section.terminalBehavior': 'Terminal Behavior',
|
||||
'hostDetails.lineTimestamps': 'Prefix output with timestamps',
|
||||
'hostDetails.lineTimestamps.desc': 'Add local time before visible output lines for this host. Disable it for prompts that render incorrectly when output is prefixed.',
|
||||
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ruCoreMessages } from './ru/core';
|
||||
import { ruVaultMessages } from './ru/vault';
|
||||
import { ruTerminalMessages } from './ru/terminal';
|
||||
import { ruAiMessages } from './ru/ai';
|
||||
import { ruSystemManagerMessages } from './ru/systemManager';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
@@ -11,6 +12,7 @@ const ru: Messages = {
|
||||
...ruVaultMessages,
|
||||
...ruTerminalMessages,
|
||||
...ruAiMessages,
|
||||
...ruSystemManagerMessages,
|
||||
};
|
||||
|
||||
export default ru;
|
||||
|
||||
@@ -106,6 +106,54 @@ export const ruAiMessages: Messages = {
|
||||
'ai.copilot.customPathPlaceholder': 'например, /usr/local/bin/copilot',
|
||||
'ai.copilot.check': 'Проверить',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
'ai.cursor.description': 'Использует Cursor SDK.',
|
||||
'ai.cursor.detecting': 'Обнаружение...',
|
||||
'ai.cursor.detected': 'Доступен',
|
||||
'ai.cursor.notFound': 'Недоступен',
|
||||
'ai.cursor.path': 'Среда:',
|
||||
'ai.cursor.notFoundHint': 'Укажите API-ключ, чтобы включить Cursor.',
|
||||
'ai.cursor.notInstalledHint': 'Cursor SDK не обнаружен.',
|
||||
'ai.cursor.installStatus': 'Cursor SDK',
|
||||
'ai.cursor.installed': 'Обнаружено',
|
||||
'ai.cursor.notInstalled': 'Не обнаружено',
|
||||
'ai.cursor.apiKeyStatus': 'API-ключ',
|
||||
'ai.cursor.apiKeyConfigured': 'Настроен',
|
||||
'ai.cursor.apiKeyMissing': 'Не указан',
|
||||
'ai.cursor.apiKeyFromEnv': 'Из окружения',
|
||||
'ai.cursor.apiKey': 'API-ключ',
|
||||
'ai.cursor.apiKeyPlaceholder': 'Введите API-ключ Cursor',
|
||||
'ai.cursor.apiKeyPlaceholder.env': 'Используется CURSOR_API_KEY; введите ключ для замены',
|
||||
'ai.cursor.apiKeyEnvHint': 'Cursor может использовать CURSOR_API_KEY из shell. Сохраняйте ключ здесь только если хотите переопределить его в Netcatty.',
|
||||
'ai.cursor.apiKeyOverrideHint': 'Netcatty сначала использует сохранённый здесь ключ, затем CURSOR_API_KEY.',
|
||||
'ai.cursor.saveApiKey': 'Сохранить',
|
||||
'ai.cursor.saved': 'Сохранено',
|
||||
'ai.cursor.showApiKey': 'Показать API-ключ',
|
||||
'ai.cursor.hideApiKey': 'Скрыть API-ключ',
|
||||
'ai.cursor.customPathPlaceholder': 'например, /usr/local/bin/cursor',
|
||||
'ai.cursor.check': 'Проверить',
|
||||
|
||||
// AI CodeBuddy Code
|
||||
'ai.codebuddy.title': 'CodeBuddy Code',
|
||||
'ai.codebuddy.description': 'Использует CodeBuddy Code через официальный Agent SDK (`@tencent-ai/agent-sdk`). После обнаружения может быть выбран как внешний агент для программирования.',
|
||||
'ai.codebuddy.detecting': 'Обнаружение...',
|
||||
'ai.codebuddy.detected': 'Обнаружен',
|
||||
'ai.codebuddy.notFound': 'Не найден',
|
||||
'ai.codebuddy.path': 'Путь:',
|
||||
'ai.codebuddy.notFoundHint': 'Не удалось найти codebuddy в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
|
||||
'ai.codebuddy.customPathPlaceholder': 'например, /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': 'Проверить',
|
||||
'ai.codebuddy.configSection': 'Аутентификация и конфигурация (необязательно)',
|
||||
'ai.codebuddy.internetEnv': 'Сетевая среда',
|
||||
'ai.codebuddy.internetEnv.default': 'По умолчанию (зарубежная)',
|
||||
'ai.codebuddy.internetEnv.internal': 'Internal',
|
||||
'ai.codebuddy.internetEnv.ioa': 'IOA',
|
||||
'ai.codebuddy.internetEnv.hint': 'Устанавливает CODEBUDDY_INTERNET_ENVIRONMENT — выберите Internal или IOA для ограниченных сетевых сред.',
|
||||
'ai.codebuddy.envVars': 'Переменные окружения',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'По одной записи KEY=VALUE на строку, передаются агенту CodeBuddy. Укажите CODEBUDDY_API_KEY или CODEBUDDY_AUTH_TOKEN для аутентификации. Хранятся локально в открытом виде.',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Агент по умолчанию',
|
||||
'ai.defaultAgent.description': 'Агент, который будет использоваться при запуске новой AI-сессии',
|
||||
@@ -127,6 +175,29 @@ export const ruAiMessages: Messages = {
|
||||
'ai.userSkills.status.ready': 'Готово',
|
||||
'ai.userSkills.status.warning': 'Предупреждение',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': 'Быстрые сообщения',
|
||||
'ai.quickMessages.description': 'Создавайте часто используемые подсказки и вставляйте их в AI-чат через / или кнопку быстрых сообщений. В отличие от user skills, быстрые сообщения заполняют поле ввода текстом.',
|
||||
'ai.quickMessages.add': 'Добавить быстрое сообщение',
|
||||
'ai.quickMessages.createTitle': 'Новое быстрое сообщение',
|
||||
'ai.quickMessages.editTitle': 'Редактировать быстрое сообщение',
|
||||
'ai.quickMessages.name': 'Название',
|
||||
'ai.quickMessages.name.placeholder': 'например: Проверить диск',
|
||||
'ai.quickMessages.slug': 'Команда',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': 'Описание (необязательно)',
|
||||
'ai.quickMessages.descriptionField.placeholder': 'Краткая подсказка о назначении',
|
||||
'ai.quickMessages.content': 'Текст сообщения',
|
||||
'ai.quickMessages.content.placeholder': 'Полный текст подсказки для вставки...',
|
||||
'ai.quickMessages.empty': 'Быстрых сообщений пока нет. Добавьте несколько часто используемых подсказок.',
|
||||
'ai.quickMessages.confirmDelete': 'Удалить быстрое сообщение «{name}»?',
|
||||
'ai.quickMessages.error.nameRequired': 'Укажите название.',
|
||||
'ai.quickMessages.error.invalidSlug': 'Команда может содержать только строчные буквы, цифры и дефисы.',
|
||||
'ai.quickMessages.error.contentRequired': 'Укажите текст сообщения.',
|
||||
'ai.quickMessages.error.slugTaken': 'Эта команда уже используется другим быстрым сообщением.',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': 'Команда конфликтует с user skill «/{slug}». Выберите другую.',
|
||||
'ai.quickMessages.error.maxItems': 'Можно сохранить не более {max} быстрых сообщений.',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'AI-провайдер не настроен. Перейдите в **Настройки → AI → Провайдеры**, чтобы добавить и включить провайдера.',
|
||||
'ai.chat.toolDenied': 'Действие было отклонено пользователем.',
|
||||
@@ -175,6 +246,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.newChat': 'Новый чат',
|
||||
'ai.chat.allSessions': 'Все сессии',
|
||||
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
|
||||
'ai.chat.usedTools': 'Использовано инструментов: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
|
||||
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
||||
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
@@ -185,6 +257,13 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.menuImage': 'Изображение',
|
||||
'ai.chat.menuMentionHost': 'Упомянуть хост',
|
||||
'ai.chat.menuUserSkills': 'Пользовательские skills',
|
||||
'ai.chat.menuSlashCommands': 'Команды /',
|
||||
'ai.chat.slashCommands': 'Команды /',
|
||||
'ai.chat.slashQuickMessages': 'Быстрые сообщения',
|
||||
'ai.chat.slashUserSkills': 'User skills',
|
||||
'ai.chat.quickMessages': 'Команды /',
|
||||
'ai.chat.slashNoResults': 'Нет подходящих команд',
|
||||
'ai.chat.slashEmptyHint': 'Добавьте подсказки в Настройки → AI → Быстрые сообщения.',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
|
||||
@@ -228,6 +307,7 @@ export const ruAiMessages: Messages = {
|
||||
'terminal.layer.switchToSplitView': 'Переключить в режим разделения',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Скрипты',
|
||||
'terminal.layer.history': 'История',
|
||||
'terminal.layer.theme': 'Тема',
|
||||
'terminal.layer.aiChat': 'AI-чат',
|
||||
'terminal.layer.movePanelLeft': 'Переместить панель влево',
|
||||
|
||||
@@ -42,6 +42,7 @@ export const ruCoreMessages: Messages = {
|
||||
'common.more': 'Ещё',
|
||||
'common.selectAHost': 'Выберите хост',
|
||||
'common.selectAHostPlaceholder': 'Выберите хост...',
|
||||
'sort.manual': 'Ручной порядок',
|
||||
'sort.az': 'А-Я',
|
||||
'sort.za': 'Я-А',
|
||||
'sort.newest': 'Сначала новые',
|
||||
@@ -266,9 +267,9 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
|
||||
'settings.appearance.customCss': 'Пользовательский CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (список терминалов в режиме Focus), terminal-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.',
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (список терминалов в режиме Focus), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (панель SFTP/скриптов/темы/AI, доступна пока открыта), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs, top-tabs-host-tree-toggle, top-tabs-quick-switcher-toggle.',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\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/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
|
||||
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
|
||||
'settings.appearance.language': 'Язык',
|
||||
'settings.appearance.language.desc': 'Выберите язык интерфейса',
|
||||
'settings.appearance.uiFont': 'Шрифт интерфейса',
|
||||
@@ -431,6 +432,15 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.connection.x11Display.desc': 'Необязательный адрес локального дисплея для перенаправления X11. Оставьте пустым, чтобы использовать системное значение по умолчанию.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Авто (:0 или DISPLAY)',
|
||||
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
|
||||
'settings.terminal.section.systemManager': 'Системный менеджер',
|
||||
'settings.terminal.systemManager.processRefreshInterval': 'Обновление списка процессов',
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'Обновление сессий tmux',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Как часто обновлять список сессий tmux.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров Docker',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять список контейнеров Docker.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Обновление статистики Docker',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Как часто обновлять CPU/память/сеть контейнеров Docker.',
|
||||
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
|
||||
'settings.terminal.serverStats.show.desc': 'Показывать загрузку CPU, памяти и диска в строке состояния терминала (только для Linux-серверов).',
|
||||
'settings.terminal.serverStats.refreshInterval': 'Интервал обновления',
|
||||
@@ -442,8 +452,6 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.rendering.renderer': 'Рендерер',
|
||||
'settings.terminal.rendering.renderer.desc': 'Выберите технологию рендеринга терминала. В режиме "Авто" на устройствах с малым объёмом памяти будет использоваться DOM. Изменения применяются к новым терминальным сессиям.',
|
||||
'settings.terminal.rendering.auto': 'Авто',
|
||||
'settings.terminal.rendering.lineTimestamps': 'Добавлять время к выводу',
|
||||
'settings.terminal.rendering.lineTimestamps.desc': 'Вставлять локальное время перед строками вывода терминала. Метка времени становится частью видимого содержимого терминала.',
|
||||
|
||||
// Settings > Terminal > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': 'Индикатор фокуса рабочей области',
|
||||
@@ -468,6 +476,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.scheme.disabled': 'Отключено',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Цифры без закреплённых вкладок',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'Если включено, Cmd/Ctrl+[1...9] переключает только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
|
||||
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
|
||||
'settings.shortcuts.resetAll': 'Сбросить все',
|
||||
'settings.shortcuts.recording': 'Нажмите клавиши...',
|
||||
|
||||
175
application/i18n/locales/ru/systemManager.ts
Normal file
175
application/i18n/locales/ru/systemManager.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruSystemManagerMessages: Messages = {
|
||||
'terminal.layer.system': 'Система',
|
||||
|
||||
'systemManager.noSession': 'Нет активного терминального сеанса.',
|
||||
'systemManager.notConnected': 'Подключитесь к хосту для управления процессами и сервисами.',
|
||||
'systemManager.empty': 'Нет данных.',
|
||||
'systemManager.tabs.processes': 'Процессы',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.popup.loading': 'Открытие терминала…',
|
||||
'systemManager.popup.startupFailed': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
|
||||
|
||||
'systemManager.errors.loadProcesses': 'Не удалось загрузить процессы',
|
||||
'systemManager.errors.loadTmux': 'Не удалось загрузить сессии tmux',
|
||||
'systemManager.errors.loadTmuxWindows': 'Не удалось загрузить окна tmux',
|
||||
'systemManager.errors.loadTmuxPanes': 'Не удалось загрузить панели tmux',
|
||||
'systemManager.errors.loadTmuxClients': 'Не удалось загрузить клиентов tmux',
|
||||
'systemManager.errors.actionFailed': 'Не удалось выполнить действие',
|
||||
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
|
||||
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
|
||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
|
||||
|
||||
'systemManager.processes.search': 'Поиск процессов…',
|
||||
'systemManager.processes.command': 'Команда',
|
||||
'systemManager.processes.user': 'Пользователь',
|
||||
'systemManager.processes.term': 'Завершить',
|
||||
'systemManager.processes.kill': 'Убить',
|
||||
'systemManager.processes.stop': 'Остановить (SIGSTOP)',
|
||||
'systemManager.processes.cont': 'Продолжить (SIGCONT)',
|
||||
'systemManager.processes.hup': 'Сигнал SIGHUP',
|
||||
'systemManager.processes.renice': 'Renice',
|
||||
'systemManager.processes.renicePrompt': 'Значение nice (-20 до 19)',
|
||||
'systemManager.processes.reniceInvalid': 'Nice должно быть от -20 до 19',
|
||||
'systemManager.processes.confirmKill': 'Отправить SIGKILL процессу {{pid}}?',
|
||||
'systemManager.processes.confirmSignal': 'Отправить SIG{{signal}} процессу {{pid}}?',
|
||||
'systemManager.processes.filter.all': 'Все',
|
||||
'systemManager.processes.filter.running': 'Активные',
|
||||
'systemManager.processes.ppid': 'Родительский PID',
|
||||
'systemManager.processes.rss': 'RSS',
|
||||
'systemManager.processes.vsz': 'Виртуальный размер',
|
||||
'systemManager.processes.elapsed': 'Время работы',
|
||||
'systemManager.processes.stat': 'Состояние',
|
||||
'systemManager.processes.meta': '{{count}} проц.',
|
||||
'systemManager.processes.state.running': 'Активен',
|
||||
'systemManager.processes.state.sleeping': 'Сон',
|
||||
'systemManager.processes.state.stopped': 'Остановлен',
|
||||
'systemManager.processes.state.zombie': 'Зомби',
|
||||
'systemManager.processes.sort.cpu': 'CPU',
|
||||
'systemManager.processes.sort.mem': 'Память',
|
||||
'systemManager.processes.sort.pid': 'PID',
|
||||
'systemManager.processes.sort.command': 'Команда',
|
||||
'systemManager.processes.sort.user': 'Пользователь',
|
||||
|
||||
'systemManager.common.dismiss': 'Закрыть',
|
||||
|
||||
'systemManager.tmux.new': 'Создать',
|
||||
'systemManager.tmux.search': 'Поиск сессий…',
|
||||
'systemManager.tmux.newSessionTitle': 'Новая сессия tmux',
|
||||
'systemManager.tmux.newSessionDesc': 'Задайте имя сессии и при необходимости команду запуска.',
|
||||
'systemManager.tmux.newSessionTabCustom': 'Своя команда',
|
||||
'systemManager.tmux.newSessionTabSnippet': 'Из сниппета',
|
||||
'systemManager.tmux.pickSnippet': 'Из сниппетов',
|
||||
'systemManager.tmux.pickSnippetEmpty': 'Сниппетов пока нет — добавьте их на панели скриптов или в хранилище.',
|
||||
'systemManager.tmux.selectedSnippet': 'Выбран сниппет: {{label}}',
|
||||
'systemManager.tmux.newSessionName': 'Имя сессии',
|
||||
'systemManager.tmux.newSessionCommand': 'Команда запуска',
|
||||
'systemManager.tmux.newSessionCommandPlaceholder': 'например htop или npm run dev (необяз.)',
|
||||
'systemManager.tmux.newSessionCommandHint': 'Оставьте пустым для сессии с shell по умолчанию.',
|
||||
'systemManager.tmux.creating': 'Создание…',
|
||||
'systemManager.tmux.newSessionPlaceholder': 'my-session',
|
||||
'systemManager.tmux.newSessionRequired': 'Сначала введите имя сессии',
|
||||
'systemManager.tmux.empty': 'Нет сессий tmux',
|
||||
'systemManager.tmux.attach': 'Подключить',
|
||||
'systemManager.tmux.attached': 'Подключена',
|
||||
'systemManager.tmux.detached': 'Отключена',
|
||||
'systemManager.tmux.windows': '{{count}} окон',
|
||||
'systemManager.tmux.created': 'Создана',
|
||||
'systemManager.tmux.activity': 'Активность',
|
||||
'systemManager.tmux.rename': 'Переименовать',
|
||||
'systemManager.tmux.detach': 'Отключить всех',
|
||||
'systemManager.tmux.killSession': 'Завершить сессию',
|
||||
'systemManager.tmux.killServer': 'Остановить сервер',
|
||||
'systemManager.tmux.loadingDetails': 'Загрузка деталей…',
|
||||
'systemManager.tmux.clients': 'Подключённые клиенты',
|
||||
'systemManager.tmux.windowList': 'Окна',
|
||||
'systemManager.tmux.newWindow': 'Новое окно',
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)',
|
||||
'systemManager.tmux.noWindows': 'Нет окон',
|
||||
'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте',
|
||||
'systemManager.docker.unavailable': 'Docker недоступен на этом хосте',
|
||||
'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул',
|
||||
'systemManager.tmux.lastCommand': 'последняя команда: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'Нет панелей',
|
||||
'systemManager.tmux.panes': '{{count}} пан.',
|
||||
'systemManager.tmux.active': 'активно',
|
||||
'systemManager.tmux.unnamedWindow': 'Безымянное окно',
|
||||
'systemManager.tmux.unnamedPane': 'Безымянная панель',
|
||||
'systemManager.tmux.attachWindow': 'Подключить к окну',
|
||||
'systemManager.tmux.selectWindow': 'Выбрать окно',
|
||||
'systemManager.tmux.killWindow': 'Закрыть окно',
|
||||
'systemManager.tmux.killPane': 'Закрыть панель',
|
||||
'systemManager.tmux.splitHorizontal': 'Разделить горизонтально',
|
||||
'systemManager.tmux.splitVertical': 'Разделить вертикально',
|
||||
'systemManager.tmux.sendKeys': 'Отправить клавиши',
|
||||
'systemManager.tmux.sendKeysTo': 'Отправить клавиши в окно {{window}} панель {{pane}}',
|
||||
'systemManager.tmux.sendKeysPlaceholder': 'Команда или текст…',
|
||||
'systemManager.tmux.renameSessionPrompt': 'Переименовать сессию',
|
||||
'systemManager.tmux.renameWindowPrompt': 'Переименовать окно',
|
||||
'systemManager.tmux.windowName': 'Имя окна',
|
||||
'systemManager.tmux.confirmKillSession': 'Завершить сессию tmux «{{name}}»?',
|
||||
'systemManager.tmux.confirmDetachSession': 'Отключить всех клиентов от «{{name}}»?',
|
||||
'systemManager.tmux.confirmKillWindow': 'Закрыть окно «{{name}}»?',
|
||||
'systemManager.tmux.confirmKillPane': 'Закрыть панель #{{index}}?',
|
||||
'systemManager.tmux.confirmKillServer': 'Остановить сервер tmux? Все сессии будут завершены.',
|
||||
'systemManager.tmux.meta': '{{count}} сессий',
|
||||
|
||||
'systemManager.docker.title': 'Контейнеры',
|
||||
'systemManager.docker.subTabs.containers': 'Контейнеры',
|
||||
'systemManager.docker.subTabs.images': 'Образы',
|
||||
'systemManager.docker.empty': 'Контейнеры не найдены',
|
||||
'systemManager.docker.imagesEmpty': 'Образы не найдены',
|
||||
'systemManager.docker.search': 'Поиск контейнеров…',
|
||||
'systemManager.docker.searchImages': 'Поиск образов…',
|
||||
'systemManager.docker.filter.all': 'Все',
|
||||
'systemManager.docker.filter.running': 'Запущены',
|
||||
'systemManager.docker.filter.stopped': 'Остановлены',
|
||||
'systemManager.docker.filter.paused': 'На паузе',
|
||||
'systemManager.docker.shell': 'Shell',
|
||||
'systemManager.docker.logs': 'Логи',
|
||||
'systemManager.docker.details': 'Детали',
|
||||
'systemManager.docker.inspect': 'Inspect',
|
||||
'systemManager.docker.imageInspect': 'Inspect образа',
|
||||
'systemManager.docker.confirmRemove': 'Удалить этот контейнер?',
|
||||
'systemManager.docker.confirmKill': 'Принудительно завершить контейнер?',
|
||||
'systemManager.docker.confirmRemoveImage': 'Удалить образ «{{name}}»?',
|
||||
'systemManager.docker.confirmPrune': 'Удалить dangling-образы?',
|
||||
'systemManager.docker.confirmPruneAll': 'Удалить все неиспользуемые образы?',
|
||||
'systemManager.docker.pause': 'Пауза',
|
||||
'systemManager.docker.unpause': 'Возобновить',
|
||||
'systemManager.docker.restart': 'Перезапустить',
|
||||
'systemManager.docker.kill': 'Kill',
|
||||
'systemManager.docker.renamePrompt': 'Имя контейнера',
|
||||
'systemManager.docker.prune': 'Prune',
|
||||
'systemManager.docker.pruneAll': 'Prune all',
|
||||
'systemManager.docker.tag': 'Tag',
|
||||
'systemManager.docker.tagRepoPrompt': 'Имя репозитория',
|
||||
'systemManager.docker.tagNamePrompt': 'Имя тега',
|
||||
'systemManager.docker.meta': '{{count}} конт.',
|
||||
'systemManager.docker.imagesMeta': '{{count}} образов',
|
||||
'systemManager.docker.start': 'Запустить',
|
||||
'systemManager.docker.stop': 'Остановить',
|
||||
|
||||
'systemManager.inspect.status': 'Статус',
|
||||
'systemManager.inspect.image': 'Образ',
|
||||
'systemManager.inspect.created': 'Создан',
|
||||
'systemManager.inspect.started': 'Запущен',
|
||||
'systemManager.inspect.restartPolicy': 'Перезапуск',
|
||||
'systemManager.inspect.command': 'Команда',
|
||||
'systemManager.inspect.ports': 'Порты',
|
||||
'systemManager.inspect.networks': 'Сети',
|
||||
'systemManager.inspect.mounts': 'Тома',
|
||||
'systemManager.inspect.env': 'Окружение',
|
||||
'systemManager.inspect.labels': 'Метки',
|
||||
'systemManager.inspect.tags': 'Теги',
|
||||
'systemManager.inspect.digests': 'Дайджесты',
|
||||
'systemManager.inspect.size': 'Размер',
|
||||
'systemManager.inspect.platform': 'Платформа',
|
||||
'systemManager.inspect.workdir': 'Рабочий каталог',
|
||||
'systemManager.inspect.exposedPorts': 'Открытые порты',
|
||||
'systemManager.inspect.showRaw': 'JSON',
|
||||
'systemManager.inspect.hideRaw': 'Скрыть JSON',
|
||||
};
|
||||
@@ -26,9 +26,26 @@ export const ruTerminalMessages: Messages = {
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Открыть SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Доступно после подключения',
|
||||
'terminal.toolbar.sendYmodem': 'Отправить через YMODEM',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'Другие действия',
|
||||
'terminal.toolbar.scripts': 'Скрипты',
|
||||
'terminal.toolbar.history': 'История команд',
|
||||
'history.scope.label': 'Область истории',
|
||||
'history.tab.host': 'Хост',
|
||||
'history.tab.global': 'Глобальная',
|
||||
'history.searchPlaceholder': 'Поиск по истории...',
|
||||
'history.loading': 'Загрузка удалённой истории...',
|
||||
'history.meta.count': '{count} команд',
|
||||
'history.empty.noSession': 'Откройте удалённую сессию, чтобы просмотреть историю команд.',
|
||||
'history.empty.unsupportedProtocol': 'История команд доступна только для сессий SSH/Mosh/ET.',
|
||||
'history.empty.noHistory': 'История команд на этом хосте не найдена.',
|
||||
'history.empty.noGlobalHistory': 'Глобальной истории команд пока нет. Выполненные команды появятся здесь.',
|
||||
'history.action.refresh': 'Обновить',
|
||||
'history.action.retry': 'Повторить',
|
||||
'history.action.paste': 'Вставить в терминал',
|
||||
'history.action.run': 'Выполнить в терминале',
|
||||
'history.action.saveAsSnippet': 'Сохранить как сниппет',
|
||||
'terminal.toolbar.library': 'Библиотека',
|
||||
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
|
||||
'terminal.toolbar.terminalSettings': 'Настройки терминала',
|
||||
@@ -104,10 +121,17 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.menu.pasteSelection': 'Вставить выделенное',
|
||||
'terminal.menu.selectAll': 'Выбрать всё',
|
||||
'terminal.menu.reconnect': 'Переподключиться',
|
||||
'terminal.menu.sendYmodem': 'Отправить через YMODEM',
|
||||
'terminal.menu.splitHorizontal': 'Разделить по горизонтали',
|
||||
'terminal.menu.splitVertical': 'Разделить по вертикали',
|
||||
'terminal.menu.clearBuffer': 'Очистить буфер',
|
||||
'terminal.menu.closeTerminal': 'Закрыть терминал',
|
||||
'terminal.ymodem.selectFile': 'Выберите файл для отправки',
|
||||
'terminal.ymodem.allFiles': 'Все файлы',
|
||||
'terminal.ymodem.started': 'YMODEM отправляет {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM отправил {fileName}',
|
||||
'terminal.ymodem.failed': 'Не удалось отправить через YMODEM',
|
||||
'terminal.ymodem.unavailable': 'YMODEM недоступен',
|
||||
'terminal.selection.addToAI': 'Добавить в чат',
|
||||
'terminal.selection.addToAIDesc': 'Прикрепить выбранный вывод терминала к черновику AI',
|
||||
'terminal.auth.password': 'Пароль',
|
||||
|
||||
@@ -562,6 +562,8 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.deviceType.warning': 'Команды AI-агента будут отправляться напрямую без отслеживания кода выхода. Включайте только для устройств, на которых нет стандартной оболочки.',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH-алгоритмы',
|
||||
'hostDetails.section.terminalBehavior': 'Поведение терминала',
|
||||
'hostDetails.lineTimestamps': 'Добавлять время к выводу',
|
||||
'hostDetails.lineTimestamps.desc': 'Добавлять локальное время перед видимыми строками вывода только для этого хоста. Отключите, если из-за этого некорректно отображается приглашение.',
|
||||
'hostDetails.legacyAlgorithms': 'Разрешить устаревшие алгоритмы',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Включить устаревшие SSH-алгоритмы (diffie-hellman-group1, ssh-dss, 3des-cbc и т. д.) для подключения к старому сетевому оборудованию.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'У этих алгоритмов есть известные слабые места безопасности. Включайте только для устаревших устройств, которые не поддерживают современную криптографию.',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { zhCNCoreMessages } from './zh-CN/core';
|
||||
import { zhCNVaultMessages } from './zh-CN/vault';
|
||||
import { zhCNTerminalMessages } from './zh-CN/terminal';
|
||||
import { zhCNAiMessages } from './zh-CN/ai';
|
||||
import { zhCnSystemManagerMessages } from './zh-CN/systemManager';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
@@ -11,6 +12,7 @@ const zhCN: Messages = {
|
||||
...zhCNVaultMessages,
|
||||
...zhCNTerminalMessages,
|
||||
...zhCNAiMessages,
|
||||
...zhCnSystemManagerMessages,
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
@@ -106,6 +106,54 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
|
||||
'ai.copilot.check': '检查',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
'ai.cursor.description': '使用 Cursor SDK。',
|
||||
'ai.cursor.detecting': '检测中...',
|
||||
'ai.cursor.detected': '可用',
|
||||
'ai.cursor.notFound': '不可用',
|
||||
'ai.cursor.path': '运行环境:',
|
||||
'ai.cursor.notFoundHint': '填写 API Key 后即可使用。',
|
||||
'ai.cursor.notInstalledHint': '未检测到 Cursor SDK。',
|
||||
'ai.cursor.installStatus': 'Cursor SDK',
|
||||
'ai.cursor.installed': '已检测到',
|
||||
'ai.cursor.notInstalled': '未检测到',
|
||||
'ai.cursor.apiKeyStatus': 'API Key',
|
||||
'ai.cursor.apiKeyConfigured': '已填写',
|
||||
'ai.cursor.apiKeyMissing': '未填写',
|
||||
'ai.cursor.apiKeyFromEnv': '来自环境变量',
|
||||
'ai.cursor.apiKey': 'API Key',
|
||||
'ai.cursor.apiKeyPlaceholder': '输入 Cursor API Key',
|
||||
'ai.cursor.apiKeyPlaceholder.env': '已使用 CURSOR_API_KEY;填写后会覆盖',
|
||||
'ai.cursor.apiKeyEnvHint': '已检测到本机 CURSOR_API_KEY。留空即可继续使用,填写保存后会覆盖它。',
|
||||
'ai.cursor.apiKeyOverrideHint': '当前优先使用这里保存的 Key;清空保存后会回到 CURSOR_API_KEY。',
|
||||
'ai.cursor.saveApiKey': '保存',
|
||||
'ai.cursor.saved': '已保存',
|
||||
'ai.cursor.showApiKey': '显示 API Key',
|
||||
'ai.cursor.hideApiKey': '隐藏 API Key',
|
||||
'ai.cursor.customPathPlaceholder': '例如 /usr/local/bin/cursor',
|
||||
'ai.cursor.check': '检查',
|
||||
|
||||
// AI CodeBuddy Code
|
||||
'ai.codebuddy.title': 'CodeBuddy Code',
|
||||
'ai.codebuddy.description': '通过官方 Agent SDK(`@tencent-ai/agent-sdk`)接入 CodeBuddy Code。检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.codebuddy.detecting': '检测中...',
|
||||
'ai.codebuddy.detected': '已检测到',
|
||||
'ai.codebuddy.notFound': '未找到',
|
||||
'ai.codebuddy.path': '路径:',
|
||||
'ai.codebuddy.notFoundHint': '在 PATH 中未找到 codebuddy。请安装或在下方指定可执行文件路径。',
|
||||
'ai.codebuddy.customPathPlaceholder': '例如 /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': '检查',
|
||||
'ai.codebuddy.configSection': '认证与配置(可选)',
|
||||
'ai.codebuddy.internetEnv': '网络环境',
|
||||
'ai.codebuddy.internetEnv.default': '默认(海外)',
|
||||
'ai.codebuddy.internetEnv.internal': 'Internal',
|
||||
'ai.codebuddy.internetEnv.ioa': 'IOA',
|
||||
'ai.codebuddy.internetEnv.hint': '设置 CODEBUDDY_INTERNET_ENVIRONMENT —— 受限网络环境请选择 Internal 或 IOA。',
|
||||
'ai.codebuddy.envVars': '环境变量',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': '每行一个 KEY=VALUE,传给 CodeBuddy agent。可在此设置 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成认证。明文存在本地。',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': '默认 Agent',
|
||||
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
|
||||
@@ -127,6 +175,29 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.userSkills.status.ready': '正常',
|
||||
'ai.userSkills.status.warning': '警告',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': '快捷消息',
|
||||
'ai.quickMessages.description': '创建常用提示词,在 AI 聊天框输入 / 或点击快捷按钮即可插入到输入框。与用户 Skills 不同,快捷消息会直接填入消息内容。',
|
||||
'ai.quickMessages.add': '添加快捷消息',
|
||||
'ai.quickMessages.createTitle': '新建快捷消息',
|
||||
'ai.quickMessages.editTitle': '编辑快捷消息',
|
||||
'ai.quickMessages.name': '名称',
|
||||
'ai.quickMessages.name.placeholder': '例如:检查磁盘空间',
|
||||
'ai.quickMessages.slug': '命令',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': '说明(可选)',
|
||||
'ai.quickMessages.descriptionField.placeholder': '简短描述这条快捷消息的用途',
|
||||
'ai.quickMessages.content': '消息内容',
|
||||
'ai.quickMessages.content.placeholder': '输入选择后要插入的完整提示词...',
|
||||
'ai.quickMessages.empty': '还没有快捷消息。添加几条常用提示,聊天时就能一键插入。',
|
||||
'ai.quickMessages.confirmDelete': '确定删除快捷消息「{name}」吗?',
|
||||
'ai.quickMessages.error.nameRequired': '请填写名称。',
|
||||
'ai.quickMessages.error.invalidSlug': '命令只能包含小写字母、数字和连字符。',
|
||||
'ai.quickMessages.error.contentRequired': '请填写消息内容。',
|
||||
'ai.quickMessages.error.slugTaken': '该命令已被其他快捷消息使用。',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': '该命令与用户 Skill「/{slug}」冲突,请换一个命令。',
|
||||
'ai.quickMessages.error.maxItems': '最多只能保存 {max} 条快捷消息。',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
|
||||
'ai.chat.toolDenied': '操作已被用户拒绝。',
|
||||
@@ -175,6 +246,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.newChat': '新对话',
|
||||
'ai.chat.allSessions': '所有会话',
|
||||
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
|
||||
'ai.chat.usedTools': '已使用 {n} 个工具',
|
||||
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
'ai.chat.retryHint': '你可以重新发送消息来重试。',
|
||||
@@ -185,6 +257,13 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.menuImage': '图片',
|
||||
'ai.chat.menuMentionHost': '提及主机',
|
||||
'ai.chat.menuUserSkills': '用户 Skills',
|
||||
'ai.chat.menuSlashCommands': '快捷命令',
|
||||
'ai.chat.slashCommands': '快捷命令',
|
||||
'ai.chat.slashQuickMessages': '快捷消息',
|
||||
'ai.chat.slashUserSkills': '用户 Skills',
|
||||
'ai.chat.quickMessages': '快捷命令',
|
||||
'ai.chat.slashNoResults': '没有匹配的命令',
|
||||
'ai.chat.slashEmptyHint': '可在 设置 → AI → 快捷消息 中添加常用提示词。',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
|
||||
@@ -228,6 +307,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'terminal.layer.switchToSplitView': '切换到分屏视图',
|
||||
'terminal.layer.sftp': '文件传输',
|
||||
'terminal.layer.scripts': '脚本',
|
||||
'terminal.layer.history': '命令历史',
|
||||
'terminal.layer.theme': '主题',
|
||||
'terminal.layer.aiChat': 'AI 助手',
|
||||
'terminal.layer.movePanelLeft': '面板移至左侧',
|
||||
|
||||
@@ -29,6 +29,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'common.right': '右侧',
|
||||
'common.more': '更多',
|
||||
'common.selectAHost': '选择主机',
|
||||
'sort.manual': '手动顺序',
|
||||
'sort.az': 'A-z',
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': '从新到旧',
|
||||
@@ -250,9 +251,9 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': '深色主题',
|
||||
'settings.appearance.customCss': '自定义 CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位,比如:snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar(Focus 模式终端列表)、terminal-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。',
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位,比如:snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar(Focus 模式终端列表)、terminal-host-tree-sidebar、terminal-host-tree-sidebar-content、terminal-host-tree-sidebar-row、terminal-side-panel(SFTP/脚本/主题/AI 侧栏,打开时生效)、terminal-side-panel-tabs、terminal-side-panel-content、terminal-sftp-panel、terminal-sftp-host-header、terminal-sftp-pane、terminal-sftp-toolbar、terminal-sftp-path、terminal-sftp-filter-bar、terminal-sftp-list、terminal-sftp-list-header、terminal-sftp-list-row、terminal-sftp-tree、terminal-sftp-tree-row、terminal-sftp-transfer-queue、terminal-sftp-transfer-row、terminal-split-pane、terminal-split-resizer、top-tabs、top-tabs-host-tree-toggle、top-tabs-quick-switcher-toggle。',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\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/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'settings.appearance.language': '语言',
|
||||
'settings.appearance.language.desc': '选择界面语言',
|
||||
'settings.appearance.uiFont': '界面字体',
|
||||
|
||||
175
application/i18n/locales/zh-CN/systemManager.ts
Normal file
175
application/i18n/locales/zh-CN/systemManager.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCnSystemManagerMessages: Messages = {
|
||||
'terminal.layer.system': '系统',
|
||||
|
||||
'systemManager.noSession': '没有活动的终端会话。',
|
||||
'systemManager.notConnected': '请先连接到主机以管理进程与服务。',
|
||||
'systemManager.empty': '暂无数据。',
|
||||
'systemManager.tabs.processes': '进程',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.popup.loading': '正在打开终端…',
|
||||
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
|
||||
|
||||
'systemManager.errors.loadProcesses': '加载进程列表失败',
|
||||
'systemManager.errors.loadTmux': '加载 tmux 会话失败',
|
||||
'systemManager.errors.loadTmuxWindows': '加载 tmux 窗口失败',
|
||||
'systemManager.errors.loadTmuxPanes': '加载 tmux 面板失败',
|
||||
'systemManager.errors.loadTmuxClients': '加载 tmux 客户端失败',
|
||||
'systemManager.errors.actionFailed': '操作失败',
|
||||
'systemManager.errors.loadDocker': '加载容器列表失败',
|
||||
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
|
||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
|
||||
|
||||
'systemManager.processes.search': '搜索进程…',
|
||||
'systemManager.processes.command': '命令',
|
||||
'systemManager.processes.user': '用户',
|
||||
'systemManager.processes.term': '终止',
|
||||
'systemManager.processes.kill': '强杀',
|
||||
'systemManager.processes.stop': '暂停 (SIGSTOP)',
|
||||
'systemManager.processes.cont': '继续 (SIGCONT)',
|
||||
'systemManager.processes.hup': '挂断 (SIGHUP)',
|
||||
'systemManager.processes.renice': '调整优先级',
|
||||
'systemManager.processes.renicePrompt': 'Nice 值 (-20 到 19)',
|
||||
'systemManager.processes.reniceInvalid': 'Nice 值必须在 -20 到 19 之间',
|
||||
'systemManager.processes.confirmKill': '向进程 {{pid}} 发送 SIGKILL?',
|
||||
'systemManager.processes.confirmSignal': '向进程 {{pid}} 发送 SIG{{signal}}?',
|
||||
'systemManager.processes.filter.all': '全部',
|
||||
'systemManager.processes.filter.running': '运行中',
|
||||
'systemManager.processes.ppid': '父进程 PID',
|
||||
'systemManager.processes.rss': '物理内存',
|
||||
'systemManager.processes.vsz': '虚拟内存',
|
||||
'systemManager.processes.elapsed': '运行时长',
|
||||
'systemManager.processes.stat': '状态',
|
||||
'systemManager.processes.meta': '{{count}} 个进程',
|
||||
'systemManager.processes.state.running': '运行中',
|
||||
'systemManager.processes.state.sleeping': '睡眠',
|
||||
'systemManager.processes.state.stopped': '已暂停',
|
||||
'systemManager.processes.state.zombie': '僵尸',
|
||||
'systemManager.processes.sort.cpu': 'CPU',
|
||||
'systemManager.processes.sort.mem': '内存',
|
||||
'systemManager.processes.sort.pid': 'PID',
|
||||
'systemManager.processes.sort.command': '命令',
|
||||
'systemManager.processes.sort.user': '用户',
|
||||
|
||||
'systemManager.common.dismiss': '关闭',
|
||||
|
||||
'systemManager.tmux.new': '新建',
|
||||
'systemManager.tmux.search': '搜索会话…',
|
||||
'systemManager.tmux.newSessionTitle': '新建 tmux 会话',
|
||||
'systemManager.tmux.newSessionDesc': '为会话命名,并可选在启动时执行脚本。',
|
||||
'systemManager.tmux.newSessionTabCustom': '自定义命令',
|
||||
'systemManager.tmux.newSessionTabSnippet': '从代码片段',
|
||||
'systemManager.tmux.pickSnippet': '从代码片段选择',
|
||||
'systemManager.tmux.pickSnippetEmpty': '暂无代码片段,可在脚本侧栏或仓库中添加。',
|
||||
'systemManager.tmux.selectedSnippet': '已选片段:{{label}}',
|
||||
'systemManager.tmux.newSessionName': '会话名称',
|
||||
'systemManager.tmux.newSessionCommand': '启动命令',
|
||||
'systemManager.tmux.newSessionCommandPlaceholder': '例如 htop 或 npm run dev(可选)',
|
||||
'systemManager.tmux.newSessionCommandHint': '留空则创建默认 shell 会话。',
|
||||
'systemManager.tmux.creating': '创建中…',
|
||||
'systemManager.tmux.newSessionPlaceholder': 'my-session',
|
||||
'systemManager.tmux.newSessionRequired': '请先输入会话名称',
|
||||
'systemManager.tmux.empty': '没有 tmux 会话',
|
||||
'systemManager.tmux.attach': '附加',
|
||||
'systemManager.tmux.attached': '已附加',
|
||||
'systemManager.tmux.detached': '未附加',
|
||||
'systemManager.tmux.windows': '{{count}} 个窗口',
|
||||
'systemManager.tmux.created': '创建时间',
|
||||
'systemManager.tmux.activity': '活动时间',
|
||||
'systemManager.tmux.rename': '重命名',
|
||||
'systemManager.tmux.detach': '全部分离',
|
||||
'systemManager.tmux.killSession': '结束会话',
|
||||
'systemManager.tmux.killServer': '结束 tmux 服务',
|
||||
'systemManager.tmux.loadingDetails': '正在加载详情…',
|
||||
'systemManager.tmux.clients': '已附加客户端',
|
||||
'systemManager.tmux.windowList': '窗口',
|
||||
'systemManager.tmux.newWindow': '新建窗口',
|
||||
'systemManager.tmux.newWindowPlaceholder': '窗口名称(可选)',
|
||||
'systemManager.tmux.noWindows': '没有窗口',
|
||||
'systemManager.tmux.unavailable': '此主机未检测到 tmux',
|
||||
'systemManager.docker.unavailable': '此主机未检测到 Docker',
|
||||
'systemManager.tmux.windowsMismatch': '会话显示有 {{count}} 个窗口,但 list-windows 未返回任何窗口',
|
||||
'systemManager.tmux.lastCommand': '最后执行的命令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '没有面板',
|
||||
'systemManager.tmux.panes': '{{count}} 个面板',
|
||||
'systemManager.tmux.active': '当前',
|
||||
'systemManager.tmux.unnamedWindow': '未命名窗口',
|
||||
'systemManager.tmux.unnamedPane': '未命名面板',
|
||||
'systemManager.tmux.attachWindow': '附加到窗口',
|
||||
'systemManager.tmux.selectWindow': '选中窗口',
|
||||
'systemManager.tmux.killWindow': '关闭窗口',
|
||||
'systemManager.tmux.killPane': '关闭面板',
|
||||
'systemManager.tmux.splitHorizontal': '水平分屏',
|
||||
'systemManager.tmux.splitVertical': '垂直分屏',
|
||||
'systemManager.tmux.sendKeys': '发送按键',
|
||||
'systemManager.tmux.sendKeysTo': '向窗口 {{window}} 面板 {{pane}} 发送按键',
|
||||
'systemManager.tmux.sendKeysPlaceholder': '命令或文本…',
|
||||
'systemManager.tmux.renameSessionPrompt': '重命名会话',
|
||||
'systemManager.tmux.renameWindowPrompt': '重命名窗口',
|
||||
'systemManager.tmux.windowName': '窗口名称',
|
||||
'systemManager.tmux.confirmKillSession': '确定结束 tmux 会话「{{name}}」?',
|
||||
'systemManager.tmux.confirmDetachSession': '确定将所有客户端从「{{name}}」分离?',
|
||||
'systemManager.tmux.confirmKillWindow': '确定关闭窗口「{{name}}」?',
|
||||
'systemManager.tmux.confirmKillPane': '确定关闭面板 #{{index}}?',
|
||||
'systemManager.tmux.confirmKillServer': '确定结束 tmux 服务?所有会话将被终止。',
|
||||
'systemManager.tmux.meta': '{{count}} 个会话',
|
||||
|
||||
'systemManager.docker.title': '容器',
|
||||
'systemManager.docker.subTabs.containers': '容器',
|
||||
'systemManager.docker.subTabs.images': '镜像',
|
||||
'systemManager.docker.empty': '未找到容器',
|
||||
'systemManager.docker.imagesEmpty': '未找到镜像',
|
||||
'systemManager.docker.search': '搜索容器…',
|
||||
'systemManager.docker.searchImages': '搜索镜像…',
|
||||
'systemManager.docker.filter.all': '全部',
|
||||
'systemManager.docker.filter.running': '运行中',
|
||||
'systemManager.docker.filter.stopped': '已停止',
|
||||
'systemManager.docker.filter.paused': '已暂停',
|
||||
'systemManager.docker.shell': 'Shell',
|
||||
'systemManager.docker.logs': '日志',
|
||||
'systemManager.docker.details': '详情',
|
||||
'systemManager.docker.inspect': 'Inspect',
|
||||
'systemManager.docker.imageInspect': '镜像 Inspect',
|
||||
'systemManager.docker.confirmRemove': '确定删除此容器?',
|
||||
'systemManager.docker.confirmKill': '确定强制终止此容器?',
|
||||
'systemManager.docker.confirmRemoveImage': '确定删除镜像「{{name}}」?',
|
||||
'systemManager.docker.confirmPrune': '确定清理悬空镜像?',
|
||||
'systemManager.docker.confirmPruneAll': '确定清理所有未使用镜像?',
|
||||
'systemManager.docker.pause': '暂停',
|
||||
'systemManager.docker.unpause': '恢复',
|
||||
'systemManager.docker.restart': '重启',
|
||||
'systemManager.docker.kill': '强杀',
|
||||
'systemManager.docker.renamePrompt': '容器名称',
|
||||
'systemManager.docker.prune': '清理悬空',
|
||||
'systemManager.docker.pruneAll': '清理全部',
|
||||
'systemManager.docker.tag': '打标签',
|
||||
'systemManager.docker.tagRepoPrompt': '仓库名',
|
||||
'systemManager.docker.tagNamePrompt': '标签名',
|
||||
'systemManager.docker.meta': '{{count}} 个容器',
|
||||
'systemManager.docker.imagesMeta': '{{count}} 个镜像',
|
||||
'systemManager.docker.start': '启动',
|
||||
'systemManager.docker.stop': '停止',
|
||||
|
||||
'systemManager.inspect.status': '状态',
|
||||
'systemManager.inspect.image': '镜像',
|
||||
'systemManager.inspect.created': '创建时间',
|
||||
'systemManager.inspect.started': '启动时间',
|
||||
'systemManager.inspect.restartPolicy': '重启策略',
|
||||
'systemManager.inspect.command': '启动命令',
|
||||
'systemManager.inspect.ports': '端口映射',
|
||||
'systemManager.inspect.networks': '网络',
|
||||
'systemManager.inspect.mounts': '挂载',
|
||||
'systemManager.inspect.env': '环境变量',
|
||||
'systemManager.inspect.labels': '标签',
|
||||
'systemManager.inspect.tags': '镜像标签',
|
||||
'systemManager.inspect.digests': '摘要',
|
||||
'systemManager.inspect.size': '大小',
|
||||
'systemManager.inspect.platform': '平台',
|
||||
'systemManager.inspect.workdir': '工作目录',
|
||||
'systemManager.inspect.exposedPorts': '暴露端口',
|
||||
'systemManager.inspect.showRaw': 'JSON',
|
||||
'systemManager.inspect.hideRaw': '收起 JSON',
|
||||
};
|
||||
@@ -5,6 +5,22 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH,或移除该主机的代理。',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
|
||||
// Command history side panel
|
||||
'history.scope.label': '历史范围',
|
||||
'history.tab.host': '主机',
|
||||
'history.tab.global': '全局',
|
||||
'history.searchPlaceholder': '搜索历史命令...',
|
||||
'history.loading': '正在读取远程历史...',
|
||||
'history.meta.count': '{count} 条',
|
||||
'history.empty.noSession': '请先打开一个远程会话以查看其命令历史。',
|
||||
'history.empty.unsupportedProtocol': '仅 SSH/Mosh/ET 会话支持命令历史。',
|
||||
'history.empty.noHistory': '该主机上未找到命令历史。',
|
||||
'history.empty.noGlobalHistory': '暂无全局命令历史。你执行的命令会记录在这里。',
|
||||
'history.action.refresh': '刷新',
|
||||
'history.action.retry': '重试',
|
||||
'history.action.paste': '粘贴到终端',
|
||||
'history.action.run': '在终端执行',
|
||||
'history.action.saveAsSnippet': '保存为代码片段',
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '复制文件路径',
|
||||
'sftp.context.openWith': '打开方式...',
|
||||
@@ -286,14 +302,21 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
|
||||
'settings.terminal.serverStats.refreshInterval.desc': '服务器状态刷新的频率。',
|
||||
'settings.terminal.serverStats.seconds': '秒',
|
||||
'settings.terminal.section.systemManager': '系统管理',
|
||||
'settings.terminal.systemManager.processRefreshInterval': '进程列表刷新间隔',
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系统管理侧栏中进程列表的刷新频率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 会话刷新间隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 会话列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表刷新间隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 性能数据刷新间隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/内存/网络指标的刷新频率。',
|
||||
|
||||
// Settings > Terminal > Rendering
|
||||
'settings.terminal.section.rendering': '渲染',
|
||||
'settings.terminal.rendering.renderer': '渲染器',
|
||||
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
|
||||
'settings.terminal.rendering.auto': '自动',
|
||||
'settings.terminal.rendering.lineTimestamps': '给输出加时间戳',
|
||||
'settings.terminal.rendering.lineTimestamps.desc': '在终端输出行前插入本地时间,时间戳会成为终端可见内容的一部分。',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
'settings.terminal.section.autocomplete': '自动补全',
|
||||
@@ -311,6 +334,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.scheme.disabled': '禁用',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '数字键跳过固定标签',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '开启后,Cmd/Ctrl+[1...9] 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
|
||||
'settings.shortcuts.section.custom': '自定义快捷键',
|
||||
'settings.shortcuts.resetAll': '全部重置',
|
||||
'settings.shortcuts.recording': '请按键...',
|
||||
|
||||
@@ -113,6 +113,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH 算法',
|
||||
'hostDetails.section.terminalBehavior': '终端行为',
|
||||
'hostDetails.lineTimestamps': '给输出加时间戳',
|
||||
'hostDetails.lineTimestamps.desc': '仅为这个主机的终端输出行添加本地时间。如果提示符因此渲染异常,请关闭。',
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
|
||||
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
|
||||
@@ -213,9 +215,11 @@ export const zhCNVaultMessages: Messages = {
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': '打开 SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': '连接后可用',
|
||||
'terminal.toolbar.sendYmodem': 'YMODEM 发送',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': '更多操作',
|
||||
'terminal.toolbar.scripts': '脚本',
|
||||
'terminal.toolbar.history': '命令历史',
|
||||
'terminal.toolbar.library': '库',
|
||||
'terminal.toolbar.noSnippets': '暂无代码片段',
|
||||
'terminal.toolbar.terminalSettings': '终端设置',
|
||||
@@ -291,10 +295,17 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.menu.pasteSelection': '粘贴选中文本',
|
||||
'terminal.menu.selectAll': '全选',
|
||||
'terminal.menu.reconnect': '重新连接',
|
||||
'terminal.menu.sendYmodem': 'YMODEM 发送',
|
||||
'terminal.menu.splitHorizontal': '水平分屏',
|
||||
'terminal.menu.splitVertical': '垂直分屏',
|
||||
'terminal.menu.clearBuffer': '清空缓冲区',
|
||||
'terminal.menu.closeTerminal': '关闭终端',
|
||||
'terminal.ymodem.selectFile': '选择要发送的文件',
|
||||
'terminal.ymodem.allFiles': '所有文件',
|
||||
'terminal.ymodem.started': '正在通过 YMODEM 发送 {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM 已发送 {fileName}',
|
||||
'terminal.ymodem.failed': 'YMODEM 发送失败',
|
||||
'terminal.ymodem.unavailable': 'YMODEM 当前不可用',
|
||||
'terminal.selection.addToAI': '添加到对话',
|
||||
'terminal.selection.addToAIDesc': '将选中的终端输出作为附件加入 AI 草稿',
|
||||
'terminal.auth.password': '密码',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ensureDraftForScopeState,
|
||||
getDraftMutationVersionState,
|
||||
getDraftUploadGenerationState,
|
||||
pruneStaleSessionPanelViews,
|
||||
pruneTerminalScopeState,
|
||||
pruneTerminalTransientState,
|
||||
resolvePanelView,
|
||||
@@ -89,6 +90,39 @@ test("setSessionView records target session id", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneStaleSessionPanelViews resets session views that no longer exist", () => {
|
||||
const panelViewByScope = {
|
||||
"terminal:1": { mode: "session", sessionId: "deleted-session" },
|
||||
"workspace:2": { mode: "session", sessionId: "session-keep" },
|
||||
"terminal:3": { mode: "draft" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneStaleSessionPanelViews(
|
||||
panelViewByScope,
|
||||
new Set(["session-keep"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next, {
|
||||
"terminal:1": { mode: "draft" },
|
||||
"workspace:2": { mode: "session", sessionId: "session-keep" },
|
||||
"terminal:3": { mode: "draft" },
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneStaleSessionPanelViews returns the original ref when nothing is stale", () => {
|
||||
const panelViewByScope = {
|
||||
"terminal:1": { mode: "session", sessionId: "session-keep" },
|
||||
"terminal:2": { mode: "draft" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneStaleSessionPanelViews(
|
||||
panelViewByScope,
|
||||
new Set(["session-keep"]),
|
||||
);
|
||||
|
||||
assert.equal(next, panelViewByScope);
|
||||
});
|
||||
|
||||
test("clearScopeDraftState removes both the draft and current panel view", () => {
|
||||
const draftsByScope = {
|
||||
"terminal:1": createEmptyDraft("agent-alpha"),
|
||||
|
||||
@@ -115,6 +115,25 @@ export function setSessionView(
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneStaleSessionPanelViews(
|
||||
panelViewByScope: PanelViewByScope,
|
||||
validSessionIds: Set<string>,
|
||||
): PanelViewByScope {
|
||||
let next = panelViewByScope;
|
||||
|
||||
for (const [scopeKey, panelView] of Object.entries(panelViewByScope)) {
|
||||
if (panelView?.mode !== 'session' || validSessionIds.has(panelView.sessionId)) {
|
||||
continue;
|
||||
}
|
||||
const updated = setDraftView(next, scopeKey);
|
||||
if (updated !== next) {
|
||||
next = updated;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function updateDraftForScope(
|
||||
draftsByScope: DraftsByScope,
|
||||
scopeKey: string,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
|
||||
import {
|
||||
resolveTerminalSessionExitIntent,
|
||||
shouldCloseTerminalPopupOnExit,
|
||||
} from "./resolveTerminalSessionExitIntent.ts";
|
||||
|
||||
test("normal backend exited events close the session tab", () => {
|
||||
assert.deepEqual(
|
||||
@@ -30,3 +33,10 @@ test("backend closed events keep the tab and mark it disconnected", () => {
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal popup only auto-closes after clean command exit", () => {
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
|
||||
});
|
||||
|
||||
@@ -20,3 +20,7 @@ export function resolveTerminalSessionExitIntent(
|
||||
// so the user can inspect output and reconnect.
|
||||
return { kind: "markDisconnected" };
|
||||
}
|
||||
|
||||
export function shouldCloseTerminalPopupOnExit(evt: TerminalSessionExitEvent): boolean {
|
||||
return evt.reason === "exited" && evt.exitCode === 0;
|
||||
}
|
||||
|
||||
62
application/state/sessionCapabilitiesStore.ts
Normal file
62
application/state/sessionCapabilitiesStore.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { SessionCapabilities } from '../../domain/systemManager/types';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const capabilitiesBySessionId = new Map<string, SessionCapabilities>();
|
||||
const listenersBySessionId = new Map<string, Set<Listener>>();
|
||||
|
||||
function notifySession(sessionId: string) {
|
||||
listenersBySessionId.get(sessionId)?.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export const sessionCapabilitiesStore = {
|
||||
get(sessionId: string): SessionCapabilities | undefined {
|
||||
return capabilitiesBySessionId.get(sessionId);
|
||||
},
|
||||
|
||||
set(sessionId: string, capabilities: SessionCapabilities) {
|
||||
const prev = capabilitiesBySessionId.get(sessionId);
|
||||
if (
|
||||
prev
|
||||
&& prev.targetOs === capabilities.targetOs
|
||||
&& prev.hasTmux === capabilities.hasTmux
|
||||
&& prev.hasDocker === capabilities.hasDocker
|
||||
&& prev.probedAt === capabilities.probedAt
|
||||
) {
|
||||
return;
|
||||
}
|
||||
capabilitiesBySessionId.set(sessionId, capabilities);
|
||||
notifySession(sessionId);
|
||||
},
|
||||
|
||||
delete(sessionId: string) {
|
||||
if (!capabilitiesBySessionId.delete(sessionId)) return;
|
||||
notifySession(sessionId);
|
||||
listenersBySessionId.delete(sessionId);
|
||||
},
|
||||
|
||||
/** Drop cached capabilities for sessions that no longer exist. */
|
||||
prune(liveSessionIds: ReadonlySet<string>) {
|
||||
for (const sessionId of capabilitiesBySessionId.keys()) {
|
||||
if (!liveSessionIds.has(sessionId)) {
|
||||
capabilitiesBySessionId.delete(sessionId);
|
||||
listenersBySessionId.delete(sessionId);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
subscribe(sessionId: string, listener: Listener): () => void {
|
||||
let set = listenersBySessionId.get(sessionId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listenersBySessionId.set(sessionId, set);
|
||||
}
|
||||
set.add(listener);
|
||||
return () => {
|
||||
set?.delete(listener);
|
||||
if (set && set.size === 0) {
|
||||
listenersBySessionId.delete(sessionId);
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -64,6 +64,7 @@ export const DEFAULT_SHOW_RECENT_HOSTS = true;
|
||||
export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
|
||||
export const DEFAULT_SHOW_SFTP_TAB = true;
|
||||
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
|
||||
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
|
||||
|
||||
// Editor defaults
|
||||
export const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
@@ -77,6 +78,7 @@ interface UseSettingsStorageSyncParams {
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
editorWordWrap: boolean;
|
||||
sessionLogsEnabled: boolean;
|
||||
sessionLogsDir: string;
|
||||
@@ -112,6 +114,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
|
||||
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
@@ -133,7 +136,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
@@ -142,7 +145,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
@@ -156,7 +159,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
});
|
||||
@@ -166,7 +169,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
};
|
||||
@@ -380,6 +383,12 @@ export function useSettingsStorageSync({
|
||||
setShowHostTreeSidebarState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.shellOnlyTabNumberShortcuts) {
|
||||
setShellOnlyTabNumberShortcutsState(newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
@@ -448,6 +457,7 @@ export function useSettingsStorageSync({
|
||||
setShowHostTreeSidebarState,
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
setTerminalThemeDarkId,
|
||||
|
||||
16
application/state/systemManagerDiagnostics.ts
Normal file
16
application/state/systemManagerDiagnostics.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export async function writeSystemManagerDiagnostic(
|
||||
message: string,
|
||||
extra?: Record<string, unknown>,
|
||||
) {
|
||||
try {
|
||||
await netcattyBridge.get()?.logDiagnostic?.({
|
||||
source: 'system-manager',
|
||||
message,
|
||||
extra,
|
||||
});
|
||||
} catch {
|
||||
// Diagnostics must never block the user action being diagnosed.
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ function createTerminalSessionClone(
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
fontSize: session.fontSize,
|
||||
fontSizeOverride: session.fontSizeOverride,
|
||||
reuseConnectionFromSessionId: canReuseTerminalConnection(session) ? session.id : undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
|
||||
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
|
||||
import type {
|
||||
AIDraft,
|
||||
AISession,
|
||||
@@ -35,6 +38,8 @@ import {
|
||||
activateDraftView,
|
||||
clearScopeDraftState,
|
||||
ensureDraftForScopeState,
|
||||
pruneStaleSessionPanelViews,
|
||||
setDraftView,
|
||||
setSessionView,
|
||||
updateDraftForScope,
|
||||
} from './aiDraftState';
|
||||
@@ -158,6 +163,11 @@ export function useAIState() {
|
||||
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
|
||||
);
|
||||
|
||||
// ── Quick Messages (slash prompts) ──
|
||||
const [quickMessages, setQuickMessagesRaw] = useState<AIQuickMessage[]>(() =>
|
||||
sanitizeQuickMessages(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
}, [sessions]);
|
||||
@@ -187,12 +197,22 @@ export function useAIState() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
if (changed) {
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
setPanelViewByScopeRaw((prev) => {
|
||||
const next = pruneStaleSessionPanelViews(prev, validSessionIds);
|
||||
if (next === prev) {
|
||||
return prev;
|
||||
}
|
||||
setLatestAIPanelViewByScopeSnapshot(next);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
return next;
|
||||
});
|
||||
}, [sessions, activeSessionIdMap]);
|
||||
|
||||
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
|
||||
@@ -263,6 +283,16 @@ export function useAIState() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setQuickMessages = useCallback((value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => {
|
||||
setQuickMessagesRaw((prev) => {
|
||||
const nextRaw = typeof value === 'function' ? value(prev) : value;
|
||||
const next = sanitizeQuickMessages(nextRaw);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Persist helpers ──
|
||||
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
|
||||
setProvidersRaw(prev => {
|
||||
@@ -454,6 +484,11 @@ export function useAIState() {
|
||||
case STORAGE_KEY_AI_WEB_SEARCH:
|
||||
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
|
||||
break;
|
||||
case STORAGE_KEY_AI_QUICK_MESSAGES: {
|
||||
const messages = localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
setQuickMessagesRaw(sanitizeQuickMessages(messages));
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[useAIState] Cross-window sync: failed to process storage event for key', e.key, err);
|
||||
@@ -593,6 +628,19 @@ export function useAIState() {
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
setPanelViewByScopeRaw((prev) => {
|
||||
const currentPanelView = prev[scopeKey];
|
||||
if (currentPanelView?.mode !== 'session' || currentPanelView.sessionId !== sessionId) {
|
||||
return prev;
|
||||
}
|
||||
const next = setDraftView(prev, scopeKey);
|
||||
if (next === prev) {
|
||||
return prev;
|
||||
}
|
||||
setLatestAIPanelViewByScopeSnapshot(next);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [persistSessions]);
|
||||
|
||||
@@ -974,6 +1022,8 @@ export function useAIState() {
|
||||
setAgentProvider,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
setQuickMessages,
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
@@ -1029,6 +1079,8 @@ export function useAIState() {
|
||||
setAgentProvider,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
setQuickMessages,
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { DiscoveredAgent, ExternalAgentConfig } from '../../infrastructure/
|
||||
import { getExternalAgentSdkBackend } from '../../infrastructure/ai/managedAgents';
|
||||
|
||||
interface NetcattyBridge {
|
||||
aiDiscoverAgents(): Promise<DiscoveredAgent[]>;
|
||||
aiDiscoverAgents(options?: { refreshShellEnv?: boolean; apiKeyPresent?: boolean }): Promise<DiscoveredAgent[]>;
|
||||
}
|
||||
|
||||
function getBridge(): NetcattyBridge | undefined {
|
||||
@@ -19,20 +19,27 @@ export function useAgentDiscovery(
|
||||
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
|
||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||
|
||||
const discover = useCallback(async () => {
|
||||
const cursorApiKeyPresent = externalAgents.some(
|
||||
(agent) => agent.id === "discovered_cursor" && Boolean(agent.apiKey),
|
||||
);
|
||||
|
||||
const discover = useCallback(async (discoverOptions?: { refreshShellEnv?: boolean }) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge) return;
|
||||
|
||||
setIsDiscovering(true);
|
||||
try {
|
||||
const agents = await bridge.aiDiscoverAgents();
|
||||
const agents = await bridge.aiDiscoverAgents({
|
||||
...discoverOptions,
|
||||
apiKeyPresent: cursorApiKeyPresent,
|
||||
});
|
||||
setDiscoveredAgents(agents);
|
||||
} catch (err) {
|
||||
console.error('Agent discovery failed:', err);
|
||||
} finally {
|
||||
setIsDiscovering(false);
|
||||
}
|
||||
}, []);
|
||||
}, [cursorApiKeyPresent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
@@ -128,7 +135,7 @@ export function useAgentDiscovery(
|
||||
discoveredAgents,
|
||||
unconfiguredAgents,
|
||||
isDiscovering,
|
||||
rediscover: discover,
|
||||
rediscover: () => discover({ refreshShellEnv: true }),
|
||||
enableAgent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
|
||||
import { getNextVaultOrder, normalizeVaultOrder, reorderVaultItems, sortByVaultOrder, type VaultOrderPosition } from "../../domain/vaultOrder";
|
||||
import {
|
||||
STORAGE_KEY_PF_PREFER_FORM_MODE,
|
||||
STORAGE_KEY_PF_VIEW_MODE,
|
||||
@@ -30,7 +31,7 @@ let heartbeatIntervalId: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
export type { ViewMode };
|
||||
|
||||
export type SortMode = "az" | "za" | "newest" | "oldest";
|
||||
export type SortMode = "manual" | "az" | "za" | "newest" | "oldest";
|
||||
|
||||
export interface UsePortForwardingStateResult {
|
||||
rules: PortForwardingRule[];
|
||||
@@ -52,6 +53,7 @@ export interface UsePortForwardingStateResult {
|
||||
updateRule: (id: string, updates: Partial<PortForwardingRule>) => void;
|
||||
deleteRule: (id: string) => void;
|
||||
duplicateRule: (id: string) => void;
|
||||
reorderRule: (sourceId: string, targetId: string, position: VaultOrderPosition) => void;
|
||||
importRules: (rules: PortForwardingRule[]) => void;
|
||||
|
||||
setRuleStatus: (
|
||||
@@ -90,9 +92,9 @@ const notifyListeners = () => {
|
||||
};
|
||||
|
||||
const setGlobalRules = (newRules: PortForwardingRule[]) => {
|
||||
globalRules = newRules;
|
||||
globalRules = normalizeVaultOrder(newRules);
|
||||
notifyListeners();
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, newRules);
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, globalRules);
|
||||
};
|
||||
|
||||
const normalizeRulesWithConnections = (rules: PortForwardingRule[]): PortForwardingRule[] => {
|
||||
@@ -136,7 +138,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
STORAGE_KEY_PF_VIEW_MODE,
|
||||
"grid",
|
||||
);
|
||||
const [sortMode, setSortMode] = useState<SortMode>("newest");
|
||||
const [sortMode, setSortMode] = useState<SortMode>("manual");
|
||||
const [search, setSearch] = useState("");
|
||||
const [preferFormMode, setPreferFormModeState] = useState<boolean>(() => {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE) ?? false;
|
||||
@@ -249,6 +251,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: Date.now(),
|
||||
status: "inactive",
|
||||
order: getNextVaultOrder(globalRules),
|
||||
};
|
||||
const updated = [...globalRules, newRule];
|
||||
setGlobalRules(updated);
|
||||
@@ -294,6 +297,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
status: "inactive",
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
order: getNextVaultOrder(globalRules),
|
||||
};
|
||||
const updated = [...globalRules, copy];
|
||||
setGlobalRules(updated);
|
||||
@@ -302,6 +306,14 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
[],
|
||||
);
|
||||
|
||||
const reorderRule = useCallback(
|
||||
(sourceId: string, targetId: string, position: VaultOrderPosition) => {
|
||||
setGlobalRules(reorderVaultItems(globalRules, sourceId, targetId, position));
|
||||
setSortMode("manual");
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const importRules = useCallback((newRules: PortForwardingRule[]) => {
|
||||
// When clearing all rules (e.g. "Clear local data"), stop ALL tunnels
|
||||
// and broadcast per-rule reconnect cancellation. stopAllPortForwards
|
||||
@@ -444,6 +456,9 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
case "oldest":
|
||||
result.sort((a, b) => a.createdAt - b.createdAt);
|
||||
break;
|
||||
case "manual":
|
||||
result = sortByVaultOrder(result);
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -469,6 +484,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
updateRule,
|
||||
deleteRule,
|
||||
duplicateRule,
|
||||
reorderRule,
|
||||
importRules,
|
||||
|
||||
setRuleStatus,
|
||||
|
||||
174
application/state/useRemoteHistoryState.ts
Normal file
174
application/state/useRemoteHistoryState.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
mergeRemoteHistory,
|
||||
parseBashHistory,
|
||||
parseFishHistory,
|
||||
parseZshHistory,
|
||||
} from '../../domain/remoteHistory';
|
||||
import type { RemoteHistoryEntry } from '../../domain/models';
|
||||
|
||||
export interface RemoteHistoryHostState {
|
||||
entries: RemoteHistoryEntry[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
fetchedAt: number | null;
|
||||
}
|
||||
|
||||
const EMPTY_STATE: RemoteHistoryHostState = {
|
||||
entries: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
fetchedAt: null,
|
||||
};
|
||||
|
||||
const PENDING_RETRY_MS = 1500;
|
||||
const PENDING_MAX_RETRIES = 12;
|
||||
|
||||
export interface UseRemoteHistoryState {
|
||||
getState: (
|
||||
hostId: string | null | undefined,
|
||||
sessionId?: string | null,
|
||||
) => RemoteHistoryHostState;
|
||||
fetch: (sessionId: string, hostId: string) => Promise<void>;
|
||||
clear: (hostId: string, sessionId?: string | null) => void;
|
||||
}
|
||||
|
||||
function cacheKey(hostId: string, sessionId: string): string {
|
||||
return `${hostId}\0${sessionId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns per-session remote shell history state. Fetches the remote host's shell
|
||||
* history via the SSH bridge — which detects the login shell and returns only
|
||||
* the matching file(s) — parses and de-dupes them, and keeps an in-memory
|
||||
* cache keyed by (hostId, sessionId). The cache is intentionally not persisted
|
||||
* — history files can contain sensitive content.
|
||||
*/
|
||||
export function useRemoteHistoryState(): UseRemoteHistoryState {
|
||||
const [byKey, setByKey] = useState<Record<string, RemoteHistoryHostState>>({});
|
||||
const requestIdByKey = useRef<Record<string, number>>({});
|
||||
|
||||
const getState = useCallback(
|
||||
(
|
||||
hostId: string | null | undefined,
|
||||
sessionId?: string | null,
|
||||
): RemoteHistoryHostState => {
|
||||
if (!hostId || !sessionId) return EMPTY_STATE;
|
||||
return byKey[cacheKey(hostId, sessionId)] ?? EMPTY_STATE;
|
||||
},
|
||||
[byKey],
|
||||
);
|
||||
|
||||
const fetch = useCallback(async (sessionId: string, hostId: string) => {
|
||||
if (!sessionId || !hostId) return;
|
||||
const key = cacheKey(hostId, sessionId);
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readRemoteHistory) {
|
||||
setByKey((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
entries: prev[key]?.entries ?? [],
|
||||
loading: false,
|
||||
error: 'Remote history is not available in this build.',
|
||||
fetchedAt: prev[key]?.fetchedAt ?? null,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const reqId = (requestIdByKey.current[key] ?? 0) + 1;
|
||||
requestIdByKey.current[key] = reqId;
|
||||
|
||||
setByKey((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
entries: prev[key]?.entries ?? [],
|
||||
loading: true,
|
||||
error: null,
|
||||
fetchedAt: prev[key]?.fetchedAt ?? null,
|
||||
},
|
||||
}));
|
||||
|
||||
const isStale = () => requestIdByKey.current[key] !== reqId;
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt <= PENDING_MAX_RETRIES; attempt += 1) {
|
||||
const result = await bridge.readRemoteHistory(sessionId, 1000);
|
||||
if (isStale()) return;
|
||||
|
||||
if (!result?.success) {
|
||||
if (result?.pending && attempt < PENDING_MAX_RETRIES) {
|
||||
await new Promise((resolve) => {
|
||||
window.setTimeout(resolve, PENDING_RETRY_MS);
|
||||
});
|
||||
if (isStale()) return;
|
||||
continue;
|
||||
}
|
||||
|
||||
setByKey((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
entries: prev[key]?.entries ?? [],
|
||||
loading: false,
|
||||
error: result?.pending
|
||||
? 'Remote history is not ready yet. Try again shortly.'
|
||||
: (result?.error || 'Failed to read remote history'),
|
||||
fetchedAt: prev[key]?.fetchedAt ?? null,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const lists: RemoteHistoryEntry[][] = [];
|
||||
if (result.shell === 'bash') {
|
||||
lists.push(parseBashHistory(result.bash ?? ''));
|
||||
} else if (result.shell === 'zsh') {
|
||||
lists.push(parseZshHistory(result.zsh ?? ''));
|
||||
} else if (result.shell === 'fish') {
|
||||
lists.push(parseFishHistory(result.fish ?? ''));
|
||||
} else {
|
||||
lists.push(parseBashHistory(result.bash ?? ''));
|
||||
lists.push(parseZshHistory(result.zsh ?? ''));
|
||||
lists.push(parseFishHistory(result.fish ?? ''));
|
||||
}
|
||||
const merged = mergeRemoteHistory(lists);
|
||||
|
||||
setByKey((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
entries: merged,
|
||||
loading: false,
|
||||
error: null,
|
||||
fetchedAt: Date.now(),
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isStale()) return;
|
||||
setByKey((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
entries: prev[key]?.entries ?? [],
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
fetchedAt: prev[key]?.fetchedAt ?? null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clear = useCallback((hostId: string, sessionId?: string | null) => {
|
||||
const key = sessionId ? cacheKey(hostId, sessionId) : hostId;
|
||||
requestIdByKey.current[key] = (requestIdByKey.current[key] ?? 0) + 1;
|
||||
setByKey((prev) => {
|
||||
if (!(key in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { getState, fetch, clear };
|
||||
}
|
||||
@@ -16,6 +16,7 @@ SplitDirection,
|
||||
SplitHint,
|
||||
updateWorkspaceSplitSizes,
|
||||
} from '../../domain/workspace';
|
||||
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
|
||||
import { buildOrderedWorkTabIds } from '../app/workTabSurface';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import {
|
||||
@@ -72,6 +73,18 @@ export const useSessionState = () => {
|
||||
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
|
||||
}, []);
|
||||
|
||||
const updateSessionFontSize = useCallback((sessionId: string, fontSize: number) => {
|
||||
setSessions(prev => prev.map(s => (
|
||||
s.id === sessionId ? { ...s, fontSize, fontSizeOverride: true } : s
|
||||
)));
|
||||
}, []);
|
||||
|
||||
const clearSessionFontSizeOverride = useCallback((sessionId: string) => {
|
||||
setSessions(prev => prev.map(s => (
|
||||
s.id === sessionId ? clearSessionFontSizeOverrideFields(s) : s
|
||||
)));
|
||||
}, []);
|
||||
|
||||
const closeWorkspace = useCallback((workspaceId: string) => {
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
|
||||
@@ -943,6 +956,8 @@ export const useSessionState = () => {
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
updateSessionFontSize,
|
||||
clearSessionFontSizeOverride,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromTargets,
|
||||
createWorkspaceFromSessions,
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import {
|
||||
@@ -87,6 +88,7 @@ import {
|
||||
DEFAULT_SHOW_RECENT_HOSTS,
|
||||
DEFAULT_SHOW_SFTP_TAB,
|
||||
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
|
||||
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
|
||||
DEFAULT_TERMINAL_THEME,
|
||||
DEFAULT_THEME,
|
||||
@@ -238,6 +240,10 @@ export const useSettingsState = () => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
return stored ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR;
|
||||
});
|
||||
const [shellOnlyTabNumberShortcuts, setShellOnlyTabNumberShortcutsState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
|
||||
});
|
||||
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
|
||||
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
|
||||
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
|
||||
@@ -536,6 +542,8 @@ export const useSettingsState = () => {
|
||||
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
|
||||
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
setShowHostTreeSidebarState(storedShowHostTreeSidebar ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR);
|
||||
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
|
||||
// Workspace focus style
|
||||
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
@@ -653,7 +661,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
@@ -662,7 +670,7 @@ export const useSettingsState = () => {
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
@@ -776,6 +784,13 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShellOnlyTabNumberShortcuts = useCallback((enabled: boolean) => {
|
||||
setShellOnlyTabNumberShortcutsState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
// Apply and persist custom CSS
|
||||
useEffect(() => {
|
||||
applyCustomCssToDocument(customCSS);
|
||||
@@ -1014,6 +1029,8 @@ export const useSettingsState = () => {
|
||||
setShowSftpTab,
|
||||
showHostTreeSidebar,
|
||||
setShowHostTreeSidebar,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
setShellOnlyTabNumberShortcuts,
|
||||
sftpTransferConcurrency,
|
||||
setSftpTransferConcurrency,
|
||||
// Editor Settings
|
||||
@@ -1058,7 +1075,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
199
application/state/useSystemManagerBackend.ts
Normal file
199
application/state/useSystemManagerBackend.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { DockerContainerAction, DockerImageManageAction, TmuxManageAction } from '../../domain/systemManager/types';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function useSystemManagerBackend() {
|
||||
const probeSystemCapabilities = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.probeSystemCapabilities) {
|
||||
return { success: false as const, error: 'probeSystemCapabilities unavailable' };
|
||||
}
|
||||
return bridge.probeSystemCapabilities(sessionId);
|
||||
}, []);
|
||||
|
||||
const listSystemProcesses = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listSystemProcesses) {
|
||||
return { success: false as const, error: 'listSystemProcesses unavailable' };
|
||||
}
|
||||
return bridge.listSystemProcesses(sessionId);
|
||||
}, []);
|
||||
|
||||
const signalSystemProcess = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
pid: number;
|
||||
signal?: string;
|
||||
nice?: number;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.signalSystemProcess) {
|
||||
return { success: false as const, error: 'signalSystemProcess unavailable' };
|
||||
}
|
||||
return bridge.signalSystemProcess(options);
|
||||
}, []);
|
||||
|
||||
const listTmuxSessions = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listTmuxSessions) {
|
||||
return { success: false as const, error: 'listTmuxSessions unavailable' };
|
||||
}
|
||||
return bridge.listTmuxSessions(sessionId);
|
||||
}, []);
|
||||
|
||||
const createTmuxSession = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
name: string;
|
||||
command?: string;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.createTmuxSession) {
|
||||
return { success: false as const, error: 'createTmuxSession unavailable' };
|
||||
}
|
||||
return bridge.createTmuxSession(options);
|
||||
}, []);
|
||||
|
||||
const listTmuxWindows = useCallback(async (options: { sessionId: string; sessionName: string }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listTmuxWindows) {
|
||||
return { success: false as const, error: 'listTmuxWindows unavailable' };
|
||||
}
|
||||
return bridge.listTmuxWindows(options);
|
||||
}, []);
|
||||
|
||||
const listTmuxPanes = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
windowIndex: number;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listTmuxPanes) {
|
||||
return { success: false as const, error: 'listTmuxPanes unavailable' };
|
||||
}
|
||||
return bridge.listTmuxPanes(options);
|
||||
}, []);
|
||||
|
||||
const listTmuxClients = useCallback(async (options: { sessionId: string; sessionName?: string }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listTmuxClients) {
|
||||
return { success: false as const, error: 'listTmuxClients unavailable' };
|
||||
}
|
||||
return bridge.listTmuxClients(options);
|
||||
}, []);
|
||||
|
||||
const tmuxAction = useCallback(async (options: { sessionId: string } & TmuxManageAction) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.tmuxAction) {
|
||||
return { success: false as const, error: 'tmuxAction unavailable' };
|
||||
}
|
||||
return bridge.tmuxAction(options);
|
||||
}, []);
|
||||
|
||||
const listDockerContainers = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listDockerContainers) {
|
||||
return { success: false as const, error: 'listDockerContainers unavailable' };
|
||||
}
|
||||
return bridge.listDockerContainers(sessionId);
|
||||
}, []);
|
||||
|
||||
const listDockerImages = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listDockerImages) {
|
||||
return { success: false as const, error: 'listDockerImages unavailable' };
|
||||
}
|
||||
return bridge.listDockerImages(sessionId);
|
||||
}, []);
|
||||
|
||||
const getDockerStats = useCallback(async (options: { sessionId: string; ids?: string[] }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getDockerStats) {
|
||||
return { success: false as const, error: 'getDockerStats unavailable' };
|
||||
}
|
||||
return bridge.getDockerStats(options);
|
||||
}, []);
|
||||
|
||||
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerInspect) {
|
||||
return { success: false as const, error: 'dockerInspect unavailable' };
|
||||
}
|
||||
return bridge.dockerInspect(options);
|
||||
}, []);
|
||||
|
||||
const dockerImageInspect = useCallback(async (options: { sessionId: string; imageId: string }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerImageInspect) {
|
||||
return { success: false as const, error: 'dockerImageInspect unavailable' };
|
||||
}
|
||||
return bridge.dockerImageInspect(options);
|
||||
}, []);
|
||||
|
||||
const dockerAction = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
action: DockerContainerAction;
|
||||
newName?: string;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerAction) {
|
||||
return { success: false as const, error: 'dockerAction unavailable' };
|
||||
}
|
||||
return bridge.dockerAction(options);
|
||||
}, []);
|
||||
|
||||
const dockerImageAction = useCallback(async (options: { sessionId: string } & DockerImageManageAction) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerImageAction) {
|
||||
return { success: false as const, error: 'dockerImageAction unavailable' };
|
||||
}
|
||||
return bridge.dockerImageAction(options);
|
||||
}, []);
|
||||
|
||||
const openTerminalPopup = useCallback(async (
|
||||
payload: Parameters<NonNullable<NetcattyBridge['openTerminalPopup']>>[0],
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openTerminalPopup) {
|
||||
return { success: false as const, error: 'openTerminalPopup unavailable' };
|
||||
}
|
||||
return bridge.openTerminalPopup(payload);
|
||||
}, []);
|
||||
|
||||
return useMemo(() => ({
|
||||
probeSystemCapabilities,
|
||||
listSystemProcesses,
|
||||
signalSystemProcess,
|
||||
listTmuxSessions,
|
||||
createTmuxSession,
|
||||
listTmuxWindows,
|
||||
listTmuxPanes,
|
||||
listTmuxClients,
|
||||
tmuxAction,
|
||||
listDockerContainers,
|
||||
listDockerImages,
|
||||
getDockerStats,
|
||||
dockerInspect,
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
dockerImageAction,
|
||||
openTerminalPopup,
|
||||
}), [
|
||||
probeSystemCapabilities,
|
||||
listSystemProcesses,
|
||||
signalSystemProcess,
|
||||
listTmuxSessions,
|
||||
createTmuxSession,
|
||||
listTmuxWindows,
|
||||
listTmuxPanes,
|
||||
listTmuxClients,
|
||||
tmuxAction,
|
||||
listDockerContainers,
|
||||
listDockerImages,
|
||||
getDockerStats,
|
||||
dockerInspect,
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
dockerImageAction,
|
||||
openTerminalPopup,
|
||||
]);
|
||||
}
|
||||
@@ -132,6 +132,11 @@ export const useTerminalBackend = () => {
|
||||
return bridge?.onConnectionReuseFallback?.(cb);
|
||||
}, []);
|
||||
|
||||
const onWindowFullScreenChanged = useCallback((cb: (isFullscreen: boolean) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onWindowFullScreenChanged?.(cb);
|
||||
}, []);
|
||||
|
||||
const onHostKeyVerification = useCallback((cb: Parameters<NonNullable<NetcattyBridge["onHostKeyVerification"]>>[0]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onHostKeyVerification?.(cb);
|
||||
@@ -170,6 +175,32 @@ export const useTerminalBackend = () => {
|
||||
return bridge.listSerialPorts();
|
||||
}, []);
|
||||
|
||||
const serialYmodemAvailable = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return !!bridge?.sendSerialYmodem;
|
||||
}, []);
|
||||
|
||||
const selectFileAvailable = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return !!bridge?.selectFile;
|
||||
}, []);
|
||||
|
||||
const sendSerialYmodem = useCallback(async (sessionId: string, filePath: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.sendSerialYmodem) return { success: false, error: 'sendSerialYmodem unavailable' };
|
||||
return bridge.sendSerialYmodem(sessionId, filePath);
|
||||
}, []);
|
||||
|
||||
const selectFile = useCallback(async (
|
||||
title?: string,
|
||||
defaultPath?: string,
|
||||
filters?: Array<{ name: string; extensions: string[] }>,
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.selectFile) return null;
|
||||
return bridge.selectFile(title, defaultPath, filters);
|
||||
}, []);
|
||||
|
||||
const getSessionPwd = useCallback(async (sessionId: string, options?: { allowHomeFallback?: boolean }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
|
||||
@@ -224,6 +255,10 @@ export const useTerminalBackend = () => {
|
||||
startLocalSession,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
serialYmodemAvailable,
|
||||
selectFileAvailable,
|
||||
sendSerialYmodem,
|
||||
selectFile,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getSessionRemoteInfo,
|
||||
@@ -240,6 +275,7 @@ export const useTerminalBackend = () => {
|
||||
onTelnetAutoLoginCancelled,
|
||||
onChainProgress,
|
||||
onConnectionReuseFallback,
|
||||
onWindowFullScreenChanged,
|
||||
onHostKeyVerification,
|
||||
respondHostKeyVerification,
|
||||
openExternal,
|
||||
@@ -260,6 +296,10 @@ export const useTerminalBackend = () => {
|
||||
startLocalSession,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
serialYmodemAvailable,
|
||||
selectFileAvailable,
|
||||
sendSerialYmodem,
|
||||
selectFile,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getSessionRemoteInfo,
|
||||
@@ -276,6 +316,7 @@ export const useTerminalBackend = () => {
|
||||
onTelnetAutoLoginCancelled,
|
||||
onChainProgress,
|
||||
onConnectionReuseFallback,
|
||||
onWindowFullScreenChanged,
|
||||
onHostKeyVerification,
|
||||
respondHostKeyVerification,
|
||||
openExternal,
|
||||
|
||||
21
application/state/useTerminalPopupWindow.ts
Normal file
21
application/state/useTerminalPopupWindow.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useCallback } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { TerminalPopupPayload } from '../../domain/systemManager/types';
|
||||
|
||||
export function useTerminalPopupWindow() {
|
||||
const close = useCallback(async () => {
|
||||
await netcattyBridge.get()?.windowClose?.();
|
||||
}, []);
|
||||
|
||||
const setWindowTitle = useCallback(async (title: string) => {
|
||||
await netcattyBridge.get()?.setWindowTitle?.(title);
|
||||
}, []);
|
||||
|
||||
const onPopupConfig = useCallback((cb: (payload: TerminalPopupPayload) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onTerminalPopupConfig) return () => {};
|
||||
return bridge.onTerminalPopupConfig(cb);
|
||||
}, []);
|
||||
|
||||
return { close, setWindowTitle, onPopupConfig };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
|
||||
import { migrateHostsFromLegacyLineTimestamps, normalizeDistroId, sanitizeHost } from "../../domain/host";
|
||||
import { sanitizeGroupConfig } from "../../domain/groupConfig";
|
||||
import { normalizeKnownHosts } from "../../domain/knownHosts";
|
||||
import {
|
||||
@@ -33,8 +33,11 @@ import {
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import { mergeGlobalHistoryOnAppend } from "../../domain/globalHistory";
|
||||
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
||||
import {
|
||||
decryptGroupConfigs,
|
||||
decryptHosts,
|
||||
@@ -89,6 +92,7 @@ const migrateKey = (key: Partial<SSHKey>): SSHKey => {
|
||||
((key.certificate ? "certificate" : "key") as KeyCategory),
|
||||
created: key.created || Date.now(),
|
||||
filePath: key.filePath,
|
||||
order: key.order,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -132,6 +136,11 @@ const pruneConnectionLogsForStorage = (logs: ConnectionLog[]): ConnectionLog[] =
|
||||
return changed ? next : logs;
|
||||
};
|
||||
|
||||
const readLegacyLineTimestampsEnabled = (): boolean => {
|
||||
const stored = localStorageAdapter.read<Record<string, unknown>>(STORAGE_KEY_TERM_SETTINGS);
|
||||
return stored?.showLineTimestamps === true;
|
||||
};
|
||||
|
||||
export const useVaultState = () => {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
@@ -167,7 +176,7 @@ export const useVaultState = () => {
|
||||
const groupConfigsReadSeq = useRef(0);
|
||||
|
||||
const updateHosts = useCallback((data: Host[]) => {
|
||||
const cleaned = data.map(sanitizeHost);
|
||||
const cleaned = normalizeVaultOrder(data.map(sanitizeHost));
|
||||
setHosts(cleaned);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
return encryptHosts(cleaned).then((enc) => {
|
||||
@@ -177,9 +186,10 @@ export const useVaultState = () => {
|
||||
}, []);
|
||||
|
||||
const updateKeys = useCallback((data: SSHKey[]) => {
|
||||
setKeys(data);
|
||||
const cleaned = normalizeVaultOrder(data);
|
||||
setKeys(cleaned);
|
||||
const ver = ++keysWriteVersion.current;
|
||||
return encryptKeys(data).then((enc) => {
|
||||
return encryptKeys(cleaned).then((enc) => {
|
||||
if (ver === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
@@ -210,8 +220,9 @@ export const useVaultState = () => {
|
||||
category: (draft.category || 'key') as KeyCategory,
|
||||
created: Date.now(),
|
||||
filePath: draft.filePath,
|
||||
order: getNextVaultOrder(keys),
|
||||
};
|
||||
const updated = [...keys, newKey];
|
||||
const updated = normalizeVaultOrder([...keys, newKey]);
|
||||
setKeys(updated);
|
||||
const ver = ++keysWriteVersion.current;
|
||||
void encryptKeys(updated).then((enc) => {
|
||||
@@ -222,26 +233,29 @@ export const useVaultState = () => {
|
||||
}, [keys]);
|
||||
|
||||
const updateIdentities = useCallback((data: Identity[]) => {
|
||||
setIdentities(data);
|
||||
const cleaned = normalizeVaultOrder(data);
|
||||
setIdentities(cleaned);
|
||||
const ver = ++identitiesWriteVersion.current;
|
||||
return encryptIdentities(data).then((enc) => {
|
||||
return encryptIdentities(cleaned).then((enc) => {
|
||||
if (ver === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateProxyProfiles = useCallback((data: ProxyProfile[]) => {
|
||||
setProxyProfiles(data);
|
||||
const cleaned = normalizeVaultOrder(data);
|
||||
setProxyProfiles(cleaned);
|
||||
const ver = ++proxyProfilesWriteVersion.current;
|
||||
return encryptProxyProfiles(data).then((enc) => {
|
||||
return encryptProxyProfiles(cleaned).then((enc) => {
|
||||
if (ver === proxyProfilesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateSnippets = useCallback((data: Snippet[]) => {
|
||||
setSnippets(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, data);
|
||||
const cleaned = normalizeVaultOrder(data);
|
||||
setSnippets(cleaned);
|
||||
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, cleaned);
|
||||
}, []);
|
||||
|
||||
const updateSnippetPackages = useCallback((data: string[]) => {
|
||||
@@ -252,11 +266,39 @@ export const useVaultState = () => {
|
||||
const updateCustomGroups = useCallback((data: string[]) => {
|
||||
setCustomGroups(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_GROUPS, data);
|
||||
}, []);
|
||||
|
||||
const groupOrderByPath = new Map<string, number>(
|
||||
data.map((path, index) => [path, (index + 1) * 1000]),
|
||||
);
|
||||
const existingConfigByPath = new Map<string, GroupConfig>(
|
||||
groupConfigs.map((config) => [config.path, config]),
|
||||
);
|
||||
const orderedConfigs = data.map((path) => {
|
||||
const existing = existingConfigByPath.get(path);
|
||||
const base: GroupConfig = existing ? { ...existing } : { path };
|
||||
return sanitizeGroupConfig({
|
||||
...base,
|
||||
path,
|
||||
order: groupOrderByPath.get(path),
|
||||
});
|
||||
});
|
||||
const retainedConfigs = groupConfigs.filter((config) => !groupOrderByPath.has(config.path));
|
||||
const cleanedGroupConfigs = normalizeVaultOrder([
|
||||
...orderedConfigs,
|
||||
...retainedConfigs.map(sanitizeGroupConfig),
|
||||
]);
|
||||
setGroupConfigs(cleanedGroupConfigs);
|
||||
const ver = ++groupConfigsWriteVersion.current;
|
||||
void encryptGroupConfigs(cleanedGroupConfigs).then((enc) => {
|
||||
if (ver === groupConfigsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
|
||||
});
|
||||
}, [groupConfigs]);
|
||||
|
||||
const updateKnownHosts = useCallback((data: KnownHost[]) => {
|
||||
setKnownHosts(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, data);
|
||||
const cleaned = normalizeVaultOrder(data);
|
||||
setKnownHosts(cleaned);
|
||||
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, cleaned);
|
||||
}, []);
|
||||
|
||||
const updateManagedSources = useCallback((data: ManagedSource[]) => {
|
||||
@@ -270,7 +312,7 @@ export const useVaultState = () => {
|
||||
// pingfang-sc / comic-sans-ms override from an older client would
|
||||
// sit in memory and re-persist with `fontFamilyOverride: true` until
|
||||
// the next reload. Mirrors updateHosts → sanitizeHost.
|
||||
const cleaned = data.map(sanitizeGroupConfig);
|
||||
const cleaned = normalizeVaultOrder(data.map(sanitizeGroupConfig));
|
||||
setGroupConfigs(cleaned);
|
||||
const ver = ++groupConfigsWriteVersion.current;
|
||||
return encryptGroupConfigs(cleaned).then((enc) => {
|
||||
@@ -306,14 +348,9 @@ export const useVaultState = () => {
|
||||
|
||||
const addShellHistoryEntry = useCallback(
|
||||
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
|
||||
const newEntry: ShellHistoryEntry = {
|
||||
...entry,
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setShellHistory((prev) => {
|
||||
// Keep only the last 1000 entries
|
||||
const updated = [newEntry, ...prev].slice(0, 1000);
|
||||
const updated = mergeGlobalHistoryOnAppend(prev, entry);
|
||||
if (updated === prev) return prev;
|
||||
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
|
||||
return updated;
|
||||
});
|
||||
@@ -400,6 +437,7 @@ export const useVaultState = () => {
|
||||
group: "",
|
||||
tags: [],
|
||||
protocol: "ssh",
|
||||
order: getNextVaultOrder(hosts),
|
||||
};
|
||||
|
||||
// Update the known host to mark it as converted using functional update
|
||||
@@ -413,7 +451,7 @@ export const useVaultState = () => {
|
||||
|
||||
// Add to hosts using functional update
|
||||
setHosts((prevHosts) => {
|
||||
const updated = [...prevHosts, sanitizeHost(newHost)];
|
||||
const updated = normalizeVaultOrder([...prevHosts, sanitizeHost(newHost)]);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(updated).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
@@ -423,7 +461,7 @@ export const useVaultState = () => {
|
||||
});
|
||||
|
||||
return newHost;
|
||||
}, []);
|
||||
}, [hosts]);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
@@ -437,7 +475,12 @@ export const useVaultState = () => {
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
const decrypted = await decryptHosts(savedHosts);
|
||||
if (ver === hostsWriteVersion.current) {
|
||||
const sanitized = decrypted.map(sanitizeHost);
|
||||
const sanitized = normalizeVaultOrder(
|
||||
migrateHostsFromLegacyLineTimestamps(
|
||||
decrypted.map(sanitizeHost),
|
||||
readLegacyLineTimestampsEnabled(),
|
||||
),
|
||||
);
|
||||
setHosts(sanitized);
|
||||
encryptHosts(sanitized).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
@@ -474,8 +517,9 @@ export const useVaultState = () => {
|
||||
const keyVer = ++keysWriteVersion.current;
|
||||
const decryptedKeys = await decryptKeys(migratedKeys);
|
||||
if (keyVer === keysWriteVersion.current) {
|
||||
setKeys(decryptedKeys);
|
||||
encryptKeys(decryptedKeys).then((enc) => {
|
||||
const orderedKeys = normalizeVaultOrder(decryptedKeys);
|
||||
setKeys(orderedKeys);
|
||||
encryptKeys(orderedKeys).then((enc) => {
|
||||
if (keyVer === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
@@ -493,8 +537,9 @@ export const useVaultState = () => {
|
||||
const idVer = ++identitiesWriteVersion.current;
|
||||
const decryptedIds = await decryptIdentities(savedIdentities);
|
||||
if (idVer === identitiesWriteVersion.current) {
|
||||
setIdentities(decryptedIds);
|
||||
encryptIdentities(decryptedIds).then((enc) => {
|
||||
const orderedIdentities = normalizeVaultOrder(decryptedIds);
|
||||
setIdentities(orderedIdentities);
|
||||
encryptIdentities(orderedIdentities).then((enc) => {
|
||||
if (idVer === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
@@ -507,8 +552,9 @@ export const useVaultState = () => {
|
||||
const proxyVer = ++proxyProfilesWriteVersion.current;
|
||||
const decryptedProfiles = await decryptProxyProfiles(savedProxyProfiles);
|
||||
if (proxyVer === proxyProfilesWriteVersion.current) {
|
||||
setProxyProfiles(decryptedProfiles);
|
||||
encryptProxyProfiles(decryptedProfiles).then((enc) => {
|
||||
const orderedProfiles = normalizeVaultOrder(decryptedProfiles);
|
||||
setProxyProfiles(orderedProfiles);
|
||||
encryptProxyProfiles(orderedProfiles).then((enc) => {
|
||||
if (proxyVer === proxyProfilesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
|
||||
});
|
||||
@@ -523,7 +569,11 @@ export const useVaultState = () => {
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
);
|
||||
|
||||
if (savedSnippets) setSnippets(savedSnippets);
|
||||
if (savedSnippets) {
|
||||
const orderedSnippets = normalizeVaultOrder(savedSnippets);
|
||||
setSnippets(orderedSnippets);
|
||||
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, orderedSnippets);
|
||||
}
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
@@ -540,9 +590,10 @@ export const useVaultState = () => {
|
||||
);
|
||||
if (savedKnownHosts) {
|
||||
const normalized = normalizeKnownHosts(savedKnownHosts);
|
||||
setKnownHosts(normalized);
|
||||
if (normalized !== savedKnownHosts) {
|
||||
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, normalized);
|
||||
const orderedKnownHosts = normalizeVaultOrder(normalized);
|
||||
setKnownHosts(orderedKnownHosts);
|
||||
if (normalized !== savedKnownHosts || orderedKnownHosts !== normalized) {
|
||||
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, orderedKnownHosts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,7 +621,7 @@ export const useVaultState = () => {
|
||||
const gcVer = ++groupConfigsWriteVersion.current;
|
||||
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
|
||||
if (gcVer === groupConfigsWriteVersion.current) {
|
||||
const sanitizedGC = decryptedGC.map(sanitizeGroupConfig);
|
||||
const sanitizedGC = normalizeVaultOrder(decryptedGC.map(sanitizeGroupConfig));
|
||||
setGroupConfigs(sanitizedGC);
|
||||
encryptGroupConfigs(sanitizedGC).then((enc) => {
|
||||
if (gcVer === groupConfigsWriteVersion.current)
|
||||
@@ -605,7 +656,7 @@ export const useVaultState = () => {
|
||||
// Discard if a newer storage event arrived OR a local write occurred
|
||||
// during the decrypt (writeVersion would have advanced).
|
||||
if (seq === hostsReadSeq.current && writeAtStart === hostsWriteVersion.current)
|
||||
setHosts(dec.map(sanitizeHost));
|
||||
setHosts(normalizeVaultOrder(dec.map(sanitizeHost)));
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -624,7 +675,7 @@ export const useVaultState = () => {
|
||||
const writeAtStart = keysWriteVersion.current;
|
||||
decryptKeys(migratedKeys).then((dec) => {
|
||||
if (seq === keysReadSeq.current && writeAtStart === keysWriteVersion.current)
|
||||
setKeys(dec);
|
||||
setKeys(normalizeVaultOrder(dec));
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -636,7 +687,7 @@ export const useVaultState = () => {
|
||||
const writeAtStart = identitiesWriteVersion.current;
|
||||
decryptIdentities(next).then((dec) => {
|
||||
if (seq === identitiesReadSeq.current && writeAtStart === identitiesWriteVersion.current)
|
||||
setIdentities(dec);
|
||||
setIdentities(normalizeVaultOrder(dec));
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -648,14 +699,14 @@ export const useVaultState = () => {
|
||||
const writeAtStart = proxyProfilesWriteVersion.current;
|
||||
decryptProxyProfiles(next).then((dec) => {
|
||||
if (seq === proxyProfilesReadSeq.current && writeAtStart === proxyProfilesWriteVersion.current)
|
||||
setProxyProfiles(dec);
|
||||
setProxyProfiles(normalizeVaultOrder(dec));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_SNIPPETS) {
|
||||
const next = safeParse<Snippet[]>(event.newValue) ?? [];
|
||||
setSnippets(next);
|
||||
setSnippets(normalizeVaultOrder(next));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -673,7 +724,7 @@ export const useVaultState = () => {
|
||||
|
||||
if (key === STORAGE_KEY_KNOWN_HOSTS) {
|
||||
const next = safeParse<KnownHost[]>(event.newValue) ?? [];
|
||||
setKnownHosts(normalizeKnownHosts(next));
|
||||
setKnownHosts(normalizeVaultOrder(normalizeKnownHosts(next)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -702,7 +753,7 @@ export const useVaultState = () => {
|
||||
const writeAtStart = groupConfigsWriteVersion.current;
|
||||
decryptGroupConfigs(next).then((dec) => {
|
||||
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
|
||||
setGroupConfigs(dec.map(sanitizeGroupConfig));
|
||||
setGroupConfigs(normalizeVaultOrder(dec.map(sanitizeGroupConfig)));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { useCallback } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
export function subscribeWindowFullscreenChanged(
|
||||
cb: (isFullscreen: boolean) => void,
|
||||
): () => void {
|
||||
try {
|
||||
return netcattyBridge.get()?.onWindowFullScreenChanged?.(cb) ?? (() => {});
|
||||
} catch {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
export const useWindowControls = () => {
|
||||
const notifyRendererReady = useCallback(() => {
|
||||
try {
|
||||
@@ -45,10 +55,7 @@ export const useWindowControls = () => {
|
||||
return bridge?.windowIsFullscreen?.() ?? false;
|
||||
}, []);
|
||||
|
||||
const onFullscreenChanged = useCallback((cb: (isFullscreen: boolean) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onWindowFullScreenChanged?.(cb) ?? (() => {});
|
||||
}, []);
|
||||
const onFullscreenChanged = useCallback(subscribeWindowFullscreenChanged, []);
|
||||
|
||||
const onWindowCommandCloseRequested = useCallback((cb: () => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { Host } from '../../types';
|
||||
import type { VaultOrderPosition } from '../../domain/vaultOrder';
|
||||
|
||||
export interface VaultHostTreeActions {
|
||||
onDeleteHost: (host: Host) => void;
|
||||
@@ -16,6 +17,8 @@ export interface VaultHostTreeActions {
|
||||
cancelInlineHostEdit: () => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
reorderHost: (sourceHostId: string, targetHostId: string, position: VaultOrderPosition) => void;
|
||||
reorderGroup: (sourcePath: string, targetPath: string, position: VaultOrderPosition) => boolean;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
}
|
||||
|
||||
@@ -696,6 +696,49 @@ test("applySyncPayload preserves host proxy references when group configs are ab
|
||||
assert.equal("groupConfigs" in imported, false);
|
||||
});
|
||||
|
||||
test("applySyncPayload migrates legacy global line timestamps onto hosts", async () => {
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [
|
||||
{
|
||||
id: "host-1",
|
||||
label: "Inherited",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
},
|
||||
{
|
||||
id: "host-2",
|
||||
label: "Explicit",
|
||||
hostname: "example.net",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
showLineTimestamps: false,
|
||||
},
|
||||
],
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
settings: { terminalSettings: { showLineTimestamps: true } },
|
||||
};
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: (json) => {
|
||||
imported = JSON.parse(json);
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(imported);
|
||||
const hosts = imported.hosts as SyncPayload["hosts"];
|
||||
assert.equal(hosts[0]?.showLineTimestamps, true);
|
||||
assert.equal(hosts[1]?.showLineTimestamps, false);
|
||||
});
|
||||
|
||||
test("applySyncPayload waits for async vault imports", async () => {
|
||||
let finished = false;
|
||||
const payload: SyncPayload = {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
hasSyncPayloadEntityData,
|
||||
type SyncPayload,
|
||||
} from '../domain/sync';
|
||||
import { migrateHostsFromLegacyLineTimestamps } from '../domain/host';
|
||||
import {
|
||||
nextCustomKeyBindingsSyncVersion,
|
||||
parseCustomKeyBindingsStorageRecord,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
} from '../domain/customKeyBindings';
|
||||
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
|
||||
import { emitAIStateChanged } from './state/aiStateEvents';
|
||||
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
|
||||
import {
|
||||
@@ -64,6 +66,7 @@ import {
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
@@ -78,6 +81,7 @@ import {
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
@@ -192,8 +196,11 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats', 'showLineTimestamps',
|
||||
'serverStatsRefreshInterval', 'rendererType',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats',
|
||||
'serverStatsRefreshInterval',
|
||||
'systemManagerProcessRefreshInterval', 'systemManagerTmuxRefreshInterval',
|
||||
'systemManagerDockerListRefreshInterval', 'systemManagerDockerStatsRefreshInterval',
|
||||
'rendererType',
|
||||
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
|
||||
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
|
||||
] as const;
|
||||
@@ -228,6 +235,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
@@ -242,6 +250,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
] as const;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
@@ -405,6 +414,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 shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
if (shellOnlyTabNumberShortcuts != null) settings.shellOnlyTabNumberShortcuts = shellOnlyTabNumberShortcuts;
|
||||
const showHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
if (showHostTreeSidebar != null) settings.showHostTreeSidebar = showHostTreeSidebar;
|
||||
const workspaceFocusStyle = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
@@ -444,6 +455,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (agentProviderMap) ai.agentProviderMap = agentProviderMap;
|
||||
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
|
||||
const quickMessages = readArraySetting(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
if (quickMessages) ai.quickMessages = sanitizeQuickMessages(quickMessages);
|
||||
if (Object.keys(ai).length > 0) settings.ai = ai;
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : undefined;
|
||||
@@ -537,6 +550,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (settings.showSftpTab != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
|
||||
}
|
||||
if (settings.shellOnlyTabNumberShortcuts != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, settings.shellOnlyTabNumberShortcuts);
|
||||
}
|
||||
if (settings.showHostTreeSidebar != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, settings.showHostTreeSidebar);
|
||||
}
|
||||
@@ -575,6 +591,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
);
|
||||
}
|
||||
}
|
||||
if (ai.quickMessages != null) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, sanitizeQuickMessages(ai.quickMessages));
|
||||
}
|
||||
// After all AI writes, reconcile per-agent bindings against the final
|
||||
// provider list. Sync payloads can land with a new `providers` set but
|
||||
// no `agentProviderMap`, or with a stale `agentProviderMap` that
|
||||
@@ -615,6 +634,7 @@ function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']):
|
||||
touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
||||
}
|
||||
if (ai.webSearchConfig !== undefined) touched.push(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
if (ai.quickMessages != null) touched.push(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
for (const key of touched) {
|
||||
emitAIStateChanged(key);
|
||||
}
|
||||
@@ -707,10 +727,11 @@ function applyPayload(
|
||||
importers: SyncPayloadImporters,
|
||||
options: { includeLocalOnlyData: boolean },
|
||||
): Promise<void> {
|
||||
const legacyLineTimestampsEnabled = payload.settings?.terminalSettings?.showLineTimestamps === true;
|
||||
// Build the vault import object. Cloud sync intentionally ignores
|
||||
// local-only trust records even if legacy cloud snapshots still carry them.
|
||||
const vaultImport: Record<string, unknown> = {
|
||||
hosts: payload.hosts,
|
||||
hosts: migrateHostsFromLegacyLineTimestamps(payload.hosts, legacyLineTimestampsEnabled),
|
||||
keys: payload.keys,
|
||||
identities: payload.identities,
|
||||
proxyProfiles: payload.proxyProfiles,
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { type Dispatch, type SetStateAction } from 'react';
|
||||
import { History, Plus } from 'lucide-react';
|
||||
import type { AIPermissionMode, AISession, ChatMessage, DiscoveredAgent, ExternalAgentConfig, AgentModelPreset, ProviderConfig, UploadedFile } from '../infrastructure/ai/types';
|
||||
import type { UserSkillOption } from './ai/userSkillsState';
|
||||
import type { AIQuickMessage } from '../infrastructure/ai/quickMessages';
|
||||
import { Button } from './ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import AgentSelector from './ai/AgentSelector';
|
||||
@@ -65,6 +66,7 @@ interface AIChatPanelContentProps {
|
||||
terminalSessions: TerminalSessionSummary[];
|
||||
selectedUserSkills: UserSkillOption[];
|
||||
userSkillOptions: UserSkillOption[];
|
||||
quickMessages: AIQuickMessage[];
|
||||
addSelectedUserSkill: (slug: string) => void;
|
||||
removeSelectedUserSkill: (slug: string) => void;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
@@ -112,6 +114,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
terminalSessions,
|
||||
selectedUserSkills,
|
||||
userSkillOptions,
|
||||
quickMessages,
|
||||
addSelectedUserSkill,
|
||||
removeSelectedUserSkill,
|
||||
globalPermissionMode,
|
||||
@@ -263,6 +266,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkills={userSkillOptions}
|
||||
quickMessages={quickMessages}
|
||||
onAddUserSkill={addSelectedUserSkill}
|
||||
onRemoveUserSkill={removeSelectedUserSkill}
|
||||
permissionMode={globalPermissionMode}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import {
|
||||
applyDraftEntrySelection,
|
||||
applyHistorySessionSelection,
|
||||
panelViewsEqual,
|
||||
resolveDisplayedPanelView,
|
||||
resolveDisplayedSession,
|
||||
} from './ai/aiPanelViewState';
|
||||
@@ -47,7 +48,7 @@ import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligib
|
||||
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
|
||||
import { generateId, isCopilotAgentConfig, modelPresetsContainId } from './AIChatSidePanelHelpers';
|
||||
import { generateId, modelPresetsContainId, shouldLoadSdkRuntimeModels } from './AIChatSidePanelHelpers';
|
||||
import { AIChatPanelContent } from './AIChatPanelContent';
|
||||
import {
|
||||
getAIPanelProfilerProps,
|
||||
@@ -125,6 +126,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
commandBlocklist,
|
||||
maxIterations = 20,
|
||||
webSearchConfig,
|
||||
quickMessages = [],
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
@@ -252,7 +254,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
if (!explicitPanelView || normalizedPanelView === explicitPanelView) return;
|
||||
if (!explicitPanelView || panelViewsEqual(normalizedPanelView, explicitPanelView)) return;
|
||||
showDraftView(scopeKey);
|
||||
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
|
||||
|
||||
@@ -481,18 +483,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
|
||||
[currentAgentId, externalAgents],
|
||||
);
|
||||
const isCopilotExternalAgent = useMemo(
|
||||
() => isCopilotAgentConfig(currentAgentConfig),
|
||||
[currentAgentConfig],
|
||||
);
|
||||
const isCodexManagedAgent = useMemo(
|
||||
() => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'codex') : false,
|
||||
[currentAgentConfig],
|
||||
);
|
||||
const isClaudeManagedAgent = useMemo(
|
||||
() => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'claude') : false,
|
||||
[currentAgentConfig],
|
||||
);
|
||||
|
||||
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
|
||||
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
|
||||
@@ -529,7 +523,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
if (!isVisible) return;
|
||||
const sdkBackend = getExternalAgentSdkBackend(currentAgentConfig);
|
||||
if (!sdkBackend) return;
|
||||
if (!isCopilotExternalAgent && !isClaudeManagedAgent && !isCodexManagedAgent) return;
|
||||
if (!shouldLoadSdkRuntimeModels(currentAgentConfig) && !isCodexManagedAgent) return;
|
||||
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiSdkAgentListModels) return;
|
||||
@@ -569,7 +563,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVisible, currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, isCodexManagedAgent, setAgentModel]);
|
||||
}, [isVisible, currentAgentConfig, currentAgentId, isCodexManagedAgent, setAgentModel]);
|
||||
|
||||
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
|
||||
|
||||
@@ -859,22 +853,26 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
clearScopeDraft, showScopeSessionView, setActiveSessionId,
|
||||
]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (!activeSessionId) return;
|
||||
const controller = abortControllersRef.current.get(activeSessionId);
|
||||
const stopStreamingForSession = useCallback((sessionId: string) => {
|
||||
const controller = abortControllersRef.current.get(sessionId);
|
||||
controller?.abort();
|
||||
abortControllersRef.current.delete(activeSessionId);
|
||||
setStreamingForScope(activeSessionId, false);
|
||||
updateLastMessage(activeSessionId, msg => ({
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
setStreamingForScope(sessionId, false);
|
||||
updateLastMessage(sessionId, (msg) => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'cancelled' : msg.executionStatus,
|
||||
}));
|
||||
clearAllPendingApprovals(activeSessionId);
|
||||
clearAllPendingApprovals(sessionId);
|
||||
const bridge = getNetcattyBridge();
|
||||
bridge?.aiCattyCancelExec?.(activeSessionId);
|
||||
bridge?.aiSdkAgentCancel?.('', activeSessionId);
|
||||
}, [activeSessionId, setStreamingForScope, updateLastMessage, abortControllersRef]);
|
||||
bridge?.aiCattyCancelExec?.(sessionId);
|
||||
bridge?.aiSdkAgentCancel?.('', sessionId);
|
||||
}, [setStreamingForScope, updateLastMessage, abortControllersRef]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (!activeSessionId) return;
|
||||
stopStreamingForSession(activeSessionId);
|
||||
}, [activeSessionId, stopStreamingForSession]);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
@@ -890,9 +888,43 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const handleDeleteSession = useCallback(
|
||||
(e: React.MouseEvent, sessionId: string) => {
|
||||
e.stopPropagation();
|
||||
const deletingActiveSession =
|
||||
activeSessionId === sessionId
|
||||
|| persistedSessionId === sessionId
|
||||
|| (
|
||||
explicitPanelView?.mode === 'session'
|
||||
&& explicitPanelView.sessionId === sessionId
|
||||
);
|
||||
const deletingLastScopedSession =
|
||||
historySessions.length === 1 && historySessions[0]?.id === sessionId;
|
||||
const deletedSessionAgentId =
|
||||
historySessions.find((session) => session.id === sessionId)?.agentId
|
||||
?? currentAgentId;
|
||||
|
||||
if (abortControllersRef.current.has(sessionId) || streamingSessionIds.has(sessionId)) {
|
||||
stopStreamingForSession(sessionId);
|
||||
}
|
||||
|
||||
deleteSession(sessionId, scopeKey);
|
||||
|
||||
if (deletingActiveSession || deletingLastScopedSession) {
|
||||
setShowHistory(false);
|
||||
ensureScopeDraft(deletedSessionAgentId);
|
||||
}
|
||||
},
|
||||
[deleteSession, scopeKey],
|
||||
[
|
||||
activeSessionId,
|
||||
abortControllersRef,
|
||||
currentAgentId,
|
||||
deleteSession,
|
||||
ensureScopeDraft,
|
||||
explicitPanelView,
|
||||
historySessions,
|
||||
persistedSessionId,
|
||||
scopeKey,
|
||||
stopStreamingForSession,
|
||||
streamingSessionIds,
|
||||
],
|
||||
);
|
||||
|
||||
const handleAgentChange = useCallback((agentId: string) => {
|
||||
@@ -952,6 +984,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
terminalSessions={terminalSessions}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkillOptions={userSkillOptions}
|
||||
quickMessages={quickMessages}
|
||||
addSelectedUserSkill={addSelectedUserSkill}
|
||||
removeSelectedUserSkill={removeSelectedUserSkill}
|
||||
globalPermissionMode={globalPermissionMode}
|
||||
@@ -998,6 +1031,7 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
|
||||
'commandBlocklist',
|
||||
'maxIterations',
|
||||
'webSearchConfig',
|
||||
'quickMessages',
|
||||
] as const satisfies readonly (keyof AIChatSidePanelProps)[];
|
||||
|
||||
function aiChatSidePanelPropsAreEqual(
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import type { AIQuickMessage } from '../infrastructure/ai/quickMessages';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -72,6 +73,9 @@ export interface AIChatSidePanelProps {
|
||||
// Web search
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
|
||||
// Quick messages (slash prompts)
|
||||
quickMessages?: AIQuickMessage[];
|
||||
|
||||
// Context
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
|
||||
35
components/AIChatSidePanelHelpers.test.tsx
Normal file
35
components/AIChatSidePanelHelpers.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
modelPresetsContainId,
|
||||
shouldLoadSdkRuntimeModels,
|
||||
} from './AIChatSidePanelHelpers';
|
||||
import type { AgentModelPreset, ExternalAgentConfig } from '../infrastructure/ai/types';
|
||||
|
||||
test('modelPresetsContainId matches plain and thinking-level model ids', () => {
|
||||
const presets: AgentModelPreset[] = [
|
||||
{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] },
|
||||
{ id: 'claude-sonnet', name: 'Claude Sonnet' },
|
||||
];
|
||||
|
||||
assert.equal(modelPresetsContainId(presets, 'gpt-5.5/high'), true);
|
||||
assert.equal(modelPresetsContainId(presets, 'claude-sonnet'), true);
|
||||
assert.equal(modelPresetsContainId(presets, 'gpt-5.5/medium'), false);
|
||||
});
|
||||
|
||||
test('shouldLoadSdkRuntimeModels includes SDK agents with model catalogs', () => {
|
||||
const agent = (sdkBackend: string): ExternalAgentConfig => ({
|
||||
id: `discovered_${sdkBackend}`,
|
||||
name: sdkBackend,
|
||||
command: sdkBackend,
|
||||
enabled: true,
|
||||
sdkBackend,
|
||||
});
|
||||
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('claude')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('copilot')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('codebuddy')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('codex')), false);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(undefined), false);
|
||||
});
|
||||
@@ -26,6 +26,11 @@ export function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
|
||||
return tokens.some((token) => token.includes('copilot'));
|
||||
}
|
||||
|
||||
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
|
||||
const sdkBackend = getExternalAgentSdkBackend(agent);
|
||||
return sdkBackend === 'claude' || sdkBackend === 'copilot' || sdkBackend === 'codebuddy';
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
554
components/HistorySidePanel.tsx
Normal file
554
components/HistorySidePanel.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
/**
|
||||
* HistorySidePanel — command history browser for the terminal side panel.
|
||||
*
|
||||
* Two scopes:
|
||||
* - Host: remote shell history read from the focused session's history file.
|
||||
* - Global: commands recorded locally as the user types across all sessions.
|
||||
*
|
||||
* Uses VariableSizeVirtualList for performance with large lists (up to 1000
|
||||
* entries). Long commands are truncated in the list; click a row to expand the
|
||||
* full text inline below that row.
|
||||
*/
|
||||
|
||||
import {
|
||||
Clipboard as ClipboardIcon,
|
||||
FileCode,
|
||||
Globe,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Terminal as TerminalIcon,
|
||||
} from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { toGlobalHistoryDisplayEntries } from '../domain/globalHistory';
|
||||
import type { Host, RemoteHistoryEntry, ShellHistoryEntry } from '../domain/models';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { RemoteHistoryHostState } from '../application/state/useRemoteHistoryState';
|
||||
import {
|
||||
VariableSizeVirtualList,
|
||||
type VariableSizeVirtualListHandle,
|
||||
} from './ui/VariableSizeVirtualList';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
export type HistoryPanelScope = 'host' | 'global';
|
||||
|
||||
export interface HistorySidePanelProps {
|
||||
focusedHost: Host | null;
|
||||
focusedSessionId: string | null;
|
||||
state: RemoteHistoryHostState;
|
||||
globalEntries: ShellHistoryEntry[];
|
||||
onFetch: (sessionId: string, hostId: string) => void;
|
||||
/** Paste into the terminal without executing (no trailing Enter). */
|
||||
onPasteToTerminal: (command: string) => void;
|
||||
/** Write to the terminal and execute (append Enter). */
|
||||
onRunInTerminal: (command: string) => void;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
const SUPPORTED_PROTOCOLS = new Set(['ssh', 'mosh', 'et']);
|
||||
const HISTORY_ROW_HEIGHT = 36;
|
||||
const HISTORY_ROW_WITH_HOST_HEIGHT = 46;
|
||||
const DETAIL_PADDING_Y = 12;
|
||||
const DETAIL_LINE_HEIGHT = 16;
|
||||
const DETAIL_MAX_COMMAND_LINES = 3;
|
||||
const DETAIL_TIMESTAMP_HEIGHT = 14;
|
||||
const DETAIL_HOST_LABEL_HEIGHT = 14;
|
||||
const DETAIL_ACTIONS_HEIGHT = 24;
|
||||
|
||||
interface HistoryPanelEntry {
|
||||
id: string;
|
||||
command: string;
|
||||
timestamp?: number;
|
||||
hostLabel?: string;
|
||||
}
|
||||
|
||||
function getDetailRowHeight(entry: HistoryPanelEntry): number {
|
||||
const lineCount = Math.min(
|
||||
entry.command.split('\n').length,
|
||||
DETAIL_MAX_COMMAND_LINES,
|
||||
);
|
||||
const commandHeight = Math.max(lineCount, 1) * DETAIL_LINE_HEIGHT;
|
||||
const timestampBlock = entry.timestamp ? DETAIL_TIMESTAMP_HEIGHT + 4 : 0;
|
||||
const hostLabelBlock = entry.hostLabel ? DETAIL_HOST_LABEL_HEIGHT + 2 : 0;
|
||||
return DETAIL_PADDING_Y + commandHeight + timestampBlock + hostLabelBlock + 4 + DETAIL_ACTIONS_HEIGHT;
|
||||
}
|
||||
|
||||
type HistoryListRow =
|
||||
| { type: 'entry'; entry: HistoryPanelEntry }
|
||||
| { type: 'detail'; entry: HistoryPanelEntry };
|
||||
|
||||
function buildHistoryListRows(
|
||||
entries: HistoryPanelEntry[],
|
||||
selectedEntryId: string | null,
|
||||
): HistoryListRow[] {
|
||||
const rows: HistoryListRow[] = [];
|
||||
for (const entry of entries) {
|
||||
rows.push({ type: 'entry', entry });
|
||||
if (selectedEntryId === entry.id) {
|
||||
rows.push({ type: 'detail', entry });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function remoteToPanelEntries(entries: RemoteHistoryEntry[]): HistoryPanelEntry[] {
|
||||
return entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
command: entry.command,
|
||||
timestamp: entry.timestamp,
|
||||
}));
|
||||
}
|
||||
|
||||
const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
focusedHost,
|
||||
focusedSessionId,
|
||||
state,
|
||||
globalEntries,
|
||||
onFetch,
|
||||
onPasteToTerminal,
|
||||
onRunInTerminal,
|
||||
isVisible = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [scope, setScope] = useState<HistoryPanelScope>('host');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
|
||||
const listRef = useRef<VariableSizeVirtualListHandle>(null);
|
||||
|
||||
const protocol = focusedHost?.protocol;
|
||||
const isSupportedSession =
|
||||
!!focusedHost && !!focusedSessionId && SUPPORTED_PROTOCOLS.has(String(protocol ?? 'ssh'));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || scope !== 'host' || !isSupportedSession || !focusedHost || !focusedSessionId) {
|
||||
return;
|
||||
}
|
||||
if (state.loading) return;
|
||||
if (state.fetchedAt != null || state.error) return;
|
||||
onFetch(focusedSessionId, focusedHost.id);
|
||||
}, [
|
||||
isVisible,
|
||||
scope,
|
||||
isSupportedSession,
|
||||
focusedHost,
|
||||
focusedSessionId,
|
||||
state.loading,
|
||||
state.fetchedAt,
|
||||
state.error,
|
||||
onFetch,
|
||||
]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (!focusedHost || !focusedSessionId) return;
|
||||
onFetch(focusedSessionId, focusedHost.id);
|
||||
}, [focusedHost, focusedSessionId, onFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scope !== 'host') return;
|
||||
setSelectedEntryId(null);
|
||||
setSearch('');
|
||||
}, [focusedHost?.id, scope]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedEntryId(null);
|
||||
}, [scope]);
|
||||
|
||||
const sourceEntries = useMemo((): HistoryPanelEntry[] => {
|
||||
if (scope === 'global') {
|
||||
return toGlobalHistoryDisplayEntries(globalEntries);
|
||||
}
|
||||
return remoteToPanelEntries(state.entries);
|
||||
}, [scope, globalEntries, state.entries]);
|
||||
|
||||
const filtered = useMemo((): HistoryPanelEntry[] => {
|
||||
if (!search.trim()) return sourceEntries;
|
||||
const q = search.toLowerCase();
|
||||
return sourceEntries.filter(
|
||||
(entry) =>
|
||||
entry.command.toLowerCase().includes(q)
|
||||
|| entry.hostLabel?.toLowerCase().includes(q),
|
||||
);
|
||||
}, [sourceEntries, search]);
|
||||
|
||||
const listRows = useMemo(
|
||||
() => buildHistoryListRows(filtered, selectedEntryId),
|
||||
[filtered, selectedEntryId],
|
||||
);
|
||||
|
||||
const handleSaveAsSnippet = useCallback((entry: HistoryPanelEntry) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('netcatty:snippets:add', {
|
||||
detail: { command: entry.command },
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleRowClick = useCallback((entryId: string) => {
|
||||
setSelectedEntryId((current) => {
|
||||
const next = current === entryId ? null : entryId;
|
||||
if (next) {
|
||||
requestAnimationFrame(() => {
|
||||
const detailIndex = buildHistoryListRows(filtered, next).findIndex(
|
||||
(row) => row.type === 'detail' && row.entry.id === next,
|
||||
);
|
||||
if (detailIndex >= 0) {
|
||||
listRef.current?.scrollToIndex(detailIndex, 'auto');
|
||||
}
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [filtered]);
|
||||
|
||||
const getRowHeight = useCallback(
|
||||
(row: HistoryListRow) => {
|
||||
if (row.type === 'detail') return getDetailRowHeight(row.entry);
|
||||
if (scope === 'global' && row.entry.hostLabel) return HISTORY_ROW_WITH_HOST_HEIGHT;
|
||||
return HISTORY_ROW_HEIGHT;
|
||||
},
|
||||
[scope],
|
||||
);
|
||||
|
||||
const labels = useMemo(
|
||||
() => ({
|
||||
paste: t('history.action.paste'),
|
||||
run: t('history.action.run'),
|
||||
save: t('history.action.saveAsSnippet'),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const entryCount = sourceEntries.length;
|
||||
const showHostEmpty = scope === 'host' && !focusedHost;
|
||||
const showUnsupported = scope === 'host' && focusedHost && !isSupportedSession;
|
||||
const showLoading = scope === 'host' && focusedHost && isSupportedSession && state.loading && state.entries.length === 0;
|
||||
const showError = scope === 'host' && focusedHost && isSupportedSession && state.error;
|
||||
const showNoRemoteHistory =
|
||||
scope === 'host'
|
||||
&& focusedHost
|
||||
&& isSupportedSession
|
||||
&& !state.loading
|
||||
&& !state.error
|
||||
&& state.entries.length === 0;
|
||||
const showNoGlobalHistory = scope === 'global' && globalEntries.length === 0;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full flex flex-col bg-background overflow-hidden"
|
||||
data-section="history-panel"
|
||||
data-history-scope={scope}
|
||||
>
|
||||
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1.5">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('history.searchPlaceholder')}
|
||||
className="h-7 pl-7 text-xs bg-muted/30 border-none"
|
||||
/>
|
||||
</div>
|
||||
{scope === 'host' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={!isSupportedSession || state.loading}
|
||||
title={t('history.action.refresh')}
|
||||
aria-label={t('history.action.refresh')}
|
||||
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:hover:text-muted-foreground disabled:hover:bg-transparent"
|
||||
>
|
||||
<RefreshCw size={14} className={cn(state.loading && 'animate-spin')} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 text-[11px] text-muted-foreground border-b border-border/30 min-h-[28px]">
|
||||
<div
|
||||
className="inline-flex max-w-[calc(100%-3.5rem)] items-center gap-0.5"
|
||||
role="tablist"
|
||||
aria-label={t('history.scope.label')}
|
||||
>
|
||||
<ScopeTab
|
||||
active={scope === 'host'}
|
||||
label={focusedHost?.label ?? t('history.tab.host')}
|
||||
icon={<TerminalIcon size={10} className="shrink-0" />}
|
||||
onClick={() => setScope('host')}
|
||||
className="max-w-[9rem]"
|
||||
/>
|
||||
<ScopeTab
|
||||
active={scope === 'global'}
|
||||
label={t('history.tab.global')}
|
||||
icon={<Globe size={10} className="shrink-0" />}
|
||||
onClick={() => setScope('global')}
|
||||
/>
|
||||
</div>
|
||||
{entryCount > 0 && (
|
||||
<span className="ml-auto shrink-0 opacity-70">
|
||||
{t('history.meta.count', { count: entryCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
{showHostEmpty && (
|
||||
<EmptyState message={t('history.empty.noSession')} />
|
||||
)}
|
||||
|
||||
{showUnsupported && (
|
||||
<EmptyState message={t('history.empty.unsupportedProtocol')} />
|
||||
)}
|
||||
|
||||
{showLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-10 px-4 text-muted-foreground text-center">
|
||||
<RefreshCw size={20} className="opacity-60 mb-2 animate-spin" />
|
||||
<span className="text-xs">{t('history.loading')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showError && (
|
||||
<div className="px-3 py-4 text-xs text-center">
|
||||
<div className="text-destructive mb-2">{state.error}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t('history.action.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNoRemoteHistory && (
|
||||
<EmptyState message={t('history.empty.noHistory')} />
|
||||
)}
|
||||
|
||||
{showNoGlobalHistory && (
|
||||
<EmptyState message={t('history.empty.noGlobalHistory')} />
|
||||
)}
|
||||
|
||||
{filtered.length === 0 && sourceEntries.length > 0 && (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||
{t('common.noResultsFound')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{listRows.length > 0 && (
|
||||
<VariableSizeVirtualList
|
||||
ref={listRef}
|
||||
items={listRows}
|
||||
getItemHeight={getRowHeight}
|
||||
getItemKey={(row, index) =>
|
||||
row.type === 'entry' ? row.entry.id : `detail-${row.entry.id}-${index}`}
|
||||
renderItem={(row) => {
|
||||
if (row.type === 'detail') {
|
||||
return (
|
||||
<HistoryDetailStrip
|
||||
entry={row.entry}
|
||||
labels={labels}
|
||||
onRun={() => onRunInTerminal(row.entry.command)}
|
||||
onPaste={() => onPasteToTerminal(row.entry.command)}
|
||||
onSave={() => handleSaveAsSnippet(row.entry)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<HistoryRow
|
||||
entry={row.entry}
|
||||
isSelected={selectedEntryId === row.entry.id}
|
||||
showHostLabel={scope === 'global'}
|
||||
labels={labels}
|
||||
onSelect={() => handleRowClick(row.entry.id)}
|
||||
onRun={() => onRunInTerminal(row.entry.command)}
|
||||
onPaste={() => onPasteToTerminal(row.entry.command)}
|
||||
onSave={() => handleSaveAsSnippet(row.entry)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ScopeTab: React.FC<{
|
||||
active: boolean;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}> = ({ active, label, icon, onClick, className }) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded px-2 py-0.5 text-[10px] leading-4 transition-colors min-w-0 shrink whitespace-nowrap',
|
||||
active
|
||||
? 'bg-muted text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const EmptyState: React.FC<{ message: string }> = ({ message }) => (
|
||||
<div className="flex flex-col items-center justify-center py-10 px-4 text-muted-foreground text-center">
|
||||
<TerminalIcon size={24} className="opacity-40 mb-2" />
|
||||
<span className="text-xs">{message}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface HistoryDetailStripProps {
|
||||
entry: HistoryPanelEntry;
|
||||
labels: { paste: string; run: string; save: string };
|
||||
onRun: () => void;
|
||||
onPaste: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const HistoryDetailStrip: React.FC<HistoryDetailStripProps> = memo(
|
||||
({ entry, labels, onRun, onPaste, onSave }) => (
|
||||
<div
|
||||
className="border-b border-border/40 bg-muted/20 px-3 py-1.5"
|
||||
data-section="history-detail"
|
||||
>
|
||||
<div
|
||||
className="font-mono text-[11px] leading-4 whitespace-pre-wrap break-words line-clamp-3 overflow-hidden"
|
||||
style={{ overflowWrap: 'anywhere' }}
|
||||
>
|
||||
{entry.command}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-1 min-h-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
{entry.hostLabel ? (
|
||||
<span className="block text-[10px] text-muted-foreground truncate">
|
||||
{entry.hostLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{entry.timestamp ? (
|
||||
<span className="block text-[10px] text-muted-foreground truncate">
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<IconButton title={labels.run} onClick={onRun}>
|
||||
<Play size={12} />
|
||||
</IconButton>
|
||||
<IconButton title={labels.paste} onClick={onPaste}>
|
||||
<ClipboardIcon size={12} />
|
||||
</IconButton>
|
||||
<IconButton title={labels.save} onClick={onSave}>
|
||||
<FileCode size={12} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
HistoryDetailStrip.displayName = 'HistoryDetailStrip';
|
||||
|
||||
interface HistoryRowProps {
|
||||
entry: HistoryPanelEntry;
|
||||
isSelected: boolean;
|
||||
showHostLabel: boolean;
|
||||
labels: { paste: string; run: string; save: string };
|
||||
onSelect: () => void;
|
||||
onRun: () => void;
|
||||
onPaste: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const HistoryRow: React.FC<HistoryRowProps> = memo(
|
||||
({ entry, isSelected, showHostLabel, labels, onSelect, onRun, onPaste, onSave }) => {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
onSelect();
|
||||
};
|
||||
|
||||
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.detail > 1) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const rowTitle = isSelected
|
||||
? undefined
|
||||
: [entry.command, showHostLabel && entry.hostLabel ? entry.hostLabel : null]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex select-none items-center gap-2 px-3 h-full hover:bg-accent/50 transition-colors cursor-pointer',
|
||||
isSelected && 'bg-accent/30',
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isSelected}
|
||||
title={rowTitle}
|
||||
onClick={onSelect}
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className="w-0 flex-1 min-w-0">
|
||||
<div className="font-mono text-[11px] truncate whitespace-nowrap">
|
||||
{entry.command}
|
||||
</div>
|
||||
{showHostLabel && entry.hostLabel ? (
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{entry.hostLabel}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<IconButton title={labels.run} onClick={onRun}>
|
||||
<Play size={12} />
|
||||
</IconButton>
|
||||
<IconButton title={labels.paste} onClick={onPaste}>
|
||||
<ClipboardIcon size={12} />
|
||||
</IconButton>
|
||||
<IconButton title={labels.save} onClick={onSave}>
|
||||
<FileCode size={12} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
HistoryRow.displayName = 'HistoryRow';
|
||||
|
||||
const IconButton: React.FC<{
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}> = ({ title, onClick, children }) => (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={onClick}
|
||||
className="h-6 w-6 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted/70 transition-colors"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
export const HistorySidePanel = memo(HistorySidePanelInner);
|
||||
HistorySidePanel.displayName = 'HistorySidePanel';
|
||||
@@ -370,6 +370,12 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.terminalBehavior")}
|
||||
>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.lineTimestamps")}
|
||||
hint={t("hostDetails.lineTimestamps.desc")}
|
||||
enabled={!!form.showLineTimestamps}
|
||||
onToggle={() => update("showLineTimestamps", !form.showLineTimestamps)}
|
||||
/>
|
||||
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
|
||||
<Select
|
||||
value={form.backspaceBehavior ?? "default"}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useVaultHostTreeActions } from '../application/state/vaultHostTreeActio
|
||||
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig';
|
||||
import { resolveTelnetPort, resolveTelnetUsername, sanitizeHost } from '../domain/host';
|
||||
import { sortByVaultOrder } from '../domain/vaultOrder';
|
||||
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
|
||||
import { cn } from '../lib/utils';
|
||||
import { GroupConfig, GroupNode, Host } from '../types';
|
||||
@@ -20,10 +21,24 @@ import { DistroAvatar } from './DistroAvatar';
|
||||
import { HostNotesIndicator } from './host/HostNotesIndicator';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
const getTreeGroupDropIntent = (
|
||||
element: HTMLElement,
|
||||
clientY: number,
|
||||
): 'before' | 'inside' | 'after' => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const edgeSize = Math.max(8, Math.min(14, rect.height * 0.28));
|
||||
if (clientY <= rect.top + edgeSize) return 'before';
|
||||
if (clientY >= rect.bottom - edgeSize) return 'after';
|
||||
return 'inside';
|
||||
};
|
||||
|
||||
const hasDragType = (dataTransfer: DataTransfer, type: string) =>
|
||||
Array.from(dataTransfer.types).includes(type);
|
||||
|
||||
interface HostTreeViewProps {
|
||||
groupTree: GroupNode[];
|
||||
hosts: Host[];
|
||||
sortMode?: 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
sortMode?: 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
expandedPaths?: Set<string>;
|
||||
onTogglePath?: (path: string) => void;
|
||||
onExpandAll?: (paths: string[]) => void;
|
||||
@@ -55,7 +70,7 @@ interface HostTreeViewProps {
|
||||
interface TreeNodeProps {
|
||||
node: GroupNode;
|
||||
depth: number;
|
||||
sortMode: 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
sortMode: 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
expandedPaths: Set<string>;
|
||||
onToggle: (path: string) => void;
|
||||
onConnect: (host: Host) => void;
|
||||
@@ -136,10 +151,26 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
const childNodes = useMemo(() => {
|
||||
if (!node.children) return [];
|
||||
const nodes = Object.values(node.children) as unknown as GroupNode[];
|
||||
const originalIndex = new Map(nodes.map((child, index) => [child.path, index]));
|
||||
const orderByPath = new Map(
|
||||
groupConfigs
|
||||
.filter((config) => typeof config.order === 'number' && Number.isFinite(config.order))
|
||||
.map((config) => [config.path, config.order as number]),
|
||||
);
|
||||
return nodes.sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'za':
|
||||
return b.name.localeCompare(a.name);
|
||||
case 'manual': {
|
||||
const orderA = orderByPath.get(a.path);
|
||||
const orderB = orderByPath.get(b.path);
|
||||
const hasOrderA = typeof orderA === 'number' && Number.isFinite(orderA);
|
||||
const hasOrderB = typeof orderB === 'number' && Number.isFinite(orderB);
|
||||
if (hasOrderA && hasOrderB && orderA !== orderB) return orderA - orderB;
|
||||
if (hasOrderA) return -1;
|
||||
if (hasOrderB) return 1;
|
||||
return (originalIndex.get(a.path) ?? 0) - (originalIndex.get(b.path) ?? 0);
|
||||
}
|
||||
case 'newest':
|
||||
case 'oldest':
|
||||
// For groups, fall back to name sorting since groups don't have creation dates
|
||||
@@ -149,10 +180,10 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
}, [node.children, sortMode]);
|
||||
}, [groupConfigs, node.children, sortMode]);
|
||||
|
||||
const sortedHosts = useMemo(() => {
|
||||
return [...node.hosts].sort((a, b) => {
|
||||
const sorted = [...node.hosts].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'az':
|
||||
return a.label.localeCompare(b.label);
|
||||
@@ -162,10 +193,14 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
case 'oldest':
|
||||
return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
case 'manual':
|
||||
return 0;
|
||||
default:
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
});
|
||||
if (sortMode === 'manual') return sortByVaultOrder(sorted);
|
||||
return sorted;
|
||||
}, [node.hosts, sortMode]);
|
||||
|
||||
return (
|
||||
@@ -184,7 +219,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
<div
|
||||
ref={groupRowRef}
|
||||
className={cn(
|
||||
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
|
||||
"vault-drop-indicator-row flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
|
||||
getDropTargetClasses?.(node.path),
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
@@ -199,6 +234,13 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (hasDragType(e.dataTransfer, "group-path")) {
|
||||
const intent = getTreeGroupDropIntent(e.currentTarget, e.clientY);
|
||||
if (intent !== "inside") {
|
||||
setDragOverDropTarget?.(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setDragOverDropTarget?.(node.path);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
@@ -215,7 +257,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
if (groupPath) moveGroup(groupPath, node.path);
|
||||
if (groupPath && getTreeGroupDropIntent(e.currentTarget, e.clientY) === "inside") {
|
||||
moveGroup(groupPath, node.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="mr-2 flex-shrink-0 w-4 h-4 flex items-center justify-center">
|
||||
@@ -402,7 +446,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg",
|
||||
"vault-drop-indicator-row flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg",
|
||||
isSelected ? "bg-primary/10" : "",
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
@@ -562,7 +606,7 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
// Get ungrouped hosts (hosts without a group or with empty group) and sort them
|
||||
const ungroupedHosts = useMemo(() => {
|
||||
const hosts_without_group = hosts.filter(host => !host.group || host.group === '');
|
||||
return hosts_without_group.sort((a, b) => {
|
||||
const sorted = hosts_without_group.sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'az':
|
||||
return a.label.localeCompare(b.label);
|
||||
@@ -572,10 +616,14 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
case 'oldest':
|
||||
return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
case 'manual':
|
||||
return 0;
|
||||
default:
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
});
|
||||
if (sortMode === 'manual') return sortByVaultOrder(sorted);
|
||||
return sorted;
|
||||
}, [hosts, sortMode]);
|
||||
|
||||
// Sort group tree based on sort mode
|
||||
@@ -584,6 +632,8 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
switch (sortMode) {
|
||||
case 'za':
|
||||
return b.name.localeCompare(a.name);
|
||||
case 'manual':
|
||||
return 0;
|
||||
case 'newest':
|
||||
case 'oldest':
|
||||
// For groups, fall back to name sorting since groups don't have creation dates
|
||||
|
||||
@@ -13,10 +13,11 @@ import {
|
||||
Upload,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import type { GroupConfig } from "../domain/models";
|
||||
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
|
||||
import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultSectionTitleClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
|
||||
|
||||
// Import utilities and components from keychain module
|
||||
import {
|
||||
@@ -80,8 +82,10 @@ interface KeychainManagerProps {
|
||||
managedSources?: ManagedSource[];
|
||||
onSave: (key: SSHKey) => void;
|
||||
onUpdate: (key: SSHKey) => void;
|
||||
onReorderKeys?: (keys: SSHKey[]) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onSaveIdentity?: (identity: Identity) => void;
|
||||
onReorderIdentities?: (identities: Identity[]) => void;
|
||||
onDeleteIdentity?: (id: string) => void;
|
||||
onNewHost?: () => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
@@ -98,8 +102,10 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
|
||||
managedSources = [],
|
||||
onSave,
|
||||
onUpdate,
|
||||
onReorderKeys,
|
||||
onDelete,
|
||||
onSaveIdentity,
|
||||
onReorderIdentities,
|
||||
onDeleteIdentity,
|
||||
onNewHost: _onNewHost,
|
||||
onSaveHost,
|
||||
@@ -147,6 +153,7 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
|
||||
|
||||
const [showHostSelector, setShowHostSelector] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Export panel state
|
||||
const [exportLocation, setExportLocation] = useState(".ssh");
|
||||
@@ -171,6 +178,27 @@ echo $3 >> "$FILE"`);
|
||||
const [showPassphrase, setShowPassphrase] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
const keyReorder = useVaultItemReorder({
|
||||
containerRef: listRef,
|
||||
viewMode,
|
||||
dragType: "key-id",
|
||||
targetAttribute: "data-key-id",
|
||||
disabled: !onReorderKeys || search.trim().length > 0,
|
||||
onReorder: (sourceId, targetId, position) => {
|
||||
onReorderKeys?.(reorderVaultItems(keys, sourceId, targetId, position));
|
||||
},
|
||||
});
|
||||
const identityReorder = useVaultItemReorder({
|
||||
containerRef: listRef,
|
||||
viewMode,
|
||||
dragType: "identity-id",
|
||||
targetAttribute: "data-identity-id",
|
||||
disabled: !onReorderIdentities || search.trim().length > 0,
|
||||
onReorder: (sourceId, targetId, position) => {
|
||||
onReorderIdentities?.(reorderVaultItems(identities, sourceId, targetId, position));
|
||||
},
|
||||
});
|
||||
|
||||
const showError = useCallback((message: string, title = t("common.error")) => {
|
||||
toast.error(message, title);
|
||||
}, [t]);
|
||||
@@ -204,18 +232,18 @@ echo $3 >> "$FILE"`);
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
return sortByVaultOrder(result);
|
||||
}, [keys, activeFilter, search]);
|
||||
|
||||
// Filter identities based on search
|
||||
const filteredIdentities = useMemo(() => {
|
||||
if (!search.trim()) return identities;
|
||||
if (!search.trim()) return sortByVaultOrder(identities);
|
||||
const s = search.toLowerCase();
|
||||
return identities.filter(
|
||||
return sortByVaultOrder(identities.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(s) ||
|
||||
i.username.toLowerCase().includes(s),
|
||||
);
|
||||
));
|
||||
}, [identities, search]);
|
||||
|
||||
// Push a new panel onto the stack
|
||||
@@ -675,7 +703,26 @@ echo $3 >> "$FILE"`);
|
||||
</VaultPageHeader>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div
|
||||
ref={listRef}
|
||||
className="flex-1 overflow-y-auto"
|
||||
onDragOverCapture={(event) => {
|
||||
keyReorder.handleDragOverCapture(event);
|
||||
identityReorder.handleDragOverCapture(event);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
keyReorder.handleDragOver(event);
|
||||
identityReorder.handleDragOver(event);
|
||||
}}
|
||||
onDropCapture={(event) => {
|
||||
keyReorder.handleDropCapture(event);
|
||||
identityReorder.handleDropCapture(event);
|
||||
}}
|
||||
onDragEndCapture={() => {
|
||||
keyReorder.handleDragEndCapture();
|
||||
identityReorder.handleDragEndCapture();
|
||||
}}
|
||||
>
|
||||
{/* Keys Section */}
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -729,6 +776,7 @@ echo $3 >> "$FILE"`);
|
||||
(panel.type === "export" && panel.key.id === key.id)
|
||||
}
|
||||
isMac={isMacOS()}
|
||||
reorderProps={keyReorder.getItemReorderProps(key.id, `key:${key.id}`)}
|
||||
onClick={() => openKeyView(key)}
|
||||
onEdit={() => openKeyEdit(key)}
|
||||
onExport={() => openKeyExport(key)}
|
||||
@@ -768,6 +816,7 @@ echo $3 >> "$FILE"`);
|
||||
panel.type === "identity" &&
|
||||
panel.identity?.id === identity.id
|
||||
}
|
||||
reorderProps={identityReorder.getItemReorderProps(identity.id, `identity:${identity.id}`)}
|
||||
onClick={() => {
|
||||
setPanelStack([{ type: "identity", identity }]);
|
||||
setDraftIdentity({ ...identity });
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useKnownHostsBackend } from "../application/state/useKnownHostsBackend";
|
||||
import { useStoredViewMode, ViewMode } from "../application/state/useStoredViewMode";
|
||||
import { fingerprintFromPublicKey } from "../domain/knownHosts";
|
||||
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
|
||||
import { STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE } from "../infrastructure/config/storageKeys";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -45,12 +46,14 @@ import {
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
import { VaultEntityIcon, vaultPrimaryIconClass } from "./vault/VaultEntityIcon";
|
||||
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
|
||||
|
||||
interface KnownHostsManagerProps {
|
||||
knownHosts: KnownHost[];
|
||||
hosts: Host[];
|
||||
onSave: (knownHost: KnownHost) => void;
|
||||
onUpdate: (knownHost: KnownHost) => void;
|
||||
onReorder: (knownHosts: KnownHost[]) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onConvertToHost: (knownHost: KnownHost) => void;
|
||||
onImportFromFile: (hosts: KnownHost[]) => void;
|
||||
@@ -115,12 +118,13 @@ interface HostItemProps {
|
||||
knownHost: KnownHost;
|
||||
converted: boolean;
|
||||
viewMode: ViewMode;
|
||||
reorderProps?: React.HTMLAttributes<HTMLDivElement>;
|
||||
onDelete: (id: string) => void;
|
||||
onConvertToHost: (knownHost: KnownHost) => void;
|
||||
}
|
||||
|
||||
const HostItem = React.memo<HostItemProps>(
|
||||
({ knownHost, converted, viewMode, onDelete, onConvertToHost }) => {
|
||||
({ knownHost, converted, viewMode, reorderProps, onDelete, onConvertToHost }) => {
|
||||
const { t } = useI18n();
|
||||
// Disabled to reduce log noise - uncomment for debugging
|
||||
// console.log('[HostItem] render:', knownHost.hostname);
|
||||
@@ -129,9 +133,12 @@ const HostItem = React.memo<HostItemProps>(
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
{...reorderProps}
|
||||
className={cn(
|
||||
reorderProps && "vault-drop-indicator-row",
|
||||
"group cursor-pointer soft-card elevate rounded-xl h-[68px] px-3 py-2",
|
||||
converted && "opacity-60",
|
||||
reorderProps?.className,
|
||||
)}
|
||||
>
|
||||
{/* Quick action buttons on hover */}
|
||||
@@ -202,9 +209,12 @@ const HostItem = React.memo<HostItemProps>(
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
{...reorderProps}
|
||||
className={cn(
|
||||
reorderProps && "vault-drop-indicator-row",
|
||||
"group flex items-center gap-3 px-3 py-2 h-14 rounded-lg hover:bg-secondary/60 transition-colors cursor-pointer",
|
||||
converted && "opacity-60",
|
||||
reorderProps?.className,
|
||||
)}
|
||||
>
|
||||
<VaultEntityIcon
|
||||
@@ -263,6 +273,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
hosts,
|
||||
onSave: _onSave,
|
||||
onUpdate: _onUpdate,
|
||||
onReorder,
|
||||
onDelete,
|
||||
onConvertToHost,
|
||||
onImportFromFile,
|
||||
@@ -277,9 +288,10 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE,
|
||||
"grid",
|
||||
);
|
||||
const [sortMode, setSortMode] = useState<SortMode>("newest");
|
||||
const [sortMode, setSortMode] = useState<SortMode>("manual");
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const hasScannedRef = React.useRef(false);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const RENDER_LIMIT = 100; // Limit rendered items for performance
|
||||
|
||||
// Define handleScanSystem before useEffect that depends on it
|
||||
@@ -381,12 +393,14 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
return b.discoveredAt - a.discoveredAt;
|
||||
case "oldest":
|
||||
return a.discoveredAt - b.discoveredAt;
|
||||
case "manual":
|
||||
return 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
return sortMode === "manual" ? sortByVaultOrder(result) : result;
|
||||
}, [knownHosts, deferredSearch, sortMode]);
|
||||
|
||||
// Limit rendered items for performance
|
||||
@@ -461,6 +475,18 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
|
||||
const openFilePicker = useCallback(() => fileInputRef.current?.click(), []);
|
||||
|
||||
const knownHostReorder = useVaultItemReorder({
|
||||
containerRef: listRef,
|
||||
viewMode,
|
||||
dragType: "known-host-id",
|
||||
targetAttribute: "data-known-host-id",
|
||||
disabled: deferredSearch.trim().length > 0,
|
||||
onReorder: (sourceId, targetId, position) => {
|
||||
onReorder(reorderVaultItems(knownHosts, sourceId, targetId, position));
|
||||
setSortMode("manual");
|
||||
},
|
||||
});
|
||||
|
||||
// Memoize the rendered list to prevent re-renders
|
||||
const renderedItems = useMemo(() => {
|
||||
return displayedHosts.map((knownHost) => (
|
||||
@@ -469,6 +495,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
knownHost={knownHost}
|
||||
converted={convertedMap.get(knownHost.id) || false}
|
||||
viewMode={viewMode}
|
||||
reorderProps={knownHostReorder.getItemReorderProps(knownHost.id, `known:${knownHost.id}`)}
|
||||
onDelete={handleDelete}
|
||||
onConvertToHost={handleConvertToHost}
|
||||
/>
|
||||
@@ -479,6 +506,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
viewMode,
|
||||
handleDelete,
|
||||
handleConvertToHost,
|
||||
knownHostReorder,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -527,6 +555,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
modes={["manual", "az", "za", "newest", "oldest"]}
|
||||
className={vaultHeaderIconButtonClass}
|
||||
/>
|
||||
</div>
|
||||
@@ -565,12 +594,17 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div
|
||||
ref={listRef}
|
||||
className={cn(
|
||||
"p-4",
|
||||
viewMode === "grid"
|
||||
? "grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
onDragOverCapture={knownHostReorder.handleDragOverCapture}
|
||||
onDragOver={knownHostReorder.handleDragOver}
|
||||
onDropCapture={knownHostReorder.handleDropCapture}
|
||||
onDragEndCapture={knownHostReorder.handleDragEndCapture}
|
||||
>
|
||||
{displayedHosts.length === 0 ? (
|
||||
<div
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Shuffle,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import {
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
vaultSectionTitleClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
|
||||
|
||||
// Import components and utilities from port-forwarding module
|
||||
import {
|
||||
@@ -111,6 +112,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
updateRule,
|
||||
deleteRule,
|
||||
duplicateRule,
|
||||
reorderRule,
|
||||
setRuleStatus,
|
||||
startTunnel,
|
||||
stopTunnel,
|
||||
@@ -128,6 +130,16 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
const ruleListRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const ruleReorder = useVaultItemReorder({
|
||||
containerRef: ruleListRef,
|
||||
viewMode,
|
||||
dragType: "rule-id",
|
||||
targetAttribute: "data-rule-id",
|
||||
disabled: search.trim().length > 0,
|
||||
onReorder: reorderRule,
|
||||
});
|
||||
|
||||
const resolveEffectiveHost = useCallback(
|
||||
(host: Host): Host => {
|
||||
@@ -684,7 +696,10 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
{/* Sort mode toggle */}
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
onChange={(mode) => {
|
||||
if (mode !== "group") setSortMode(mode);
|
||||
}}
|
||||
modes={["manual", "az", "za", "newest", "oldest"]}
|
||||
className={vaultHeaderIconButtonClass}
|
||||
/>
|
||||
</div>
|
||||
@@ -714,11 +729,16 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={ruleListRef}
|
||||
className={cn(
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
|
||||
: "flex flex-col gap-2.5",
|
||||
)}
|
||||
onDragOverCapture={ruleReorder.handleDragOverCapture}
|
||||
onDragOver={ruleReorder.handleDragOver}
|
||||
onDropCapture={ruleReorder.handleDropCapture}
|
||||
onDragEndCapture={ruleReorder.handleDragEndCapture}
|
||||
>
|
||||
{filteredRules.map((rule) => (
|
||||
<RuleCard
|
||||
@@ -728,6 +748,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
viewMode={viewMode}
|
||||
isSelected={selectedRuleId === rule.id}
|
||||
isPending={pendingOperations.has(rule.id)}
|
||||
reorderProps={ruleReorder.getItemReorderProps(rule.id, `rule:${rule.id}`)}
|
||||
onSelect={() => {
|
||||
setSelectedRuleId(rule.id);
|
||||
startEditRule(rule);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
SquareTerminal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import {
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
isValidProxyPort,
|
||||
removeProxyProfileReferences,
|
||||
} from "../domain/proxyProfiles";
|
||||
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
|
||||
import {
|
||||
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
|
||||
} from "../infrastructure/config/storageKeys";
|
||||
@@ -67,6 +68,7 @@ import {
|
||||
vaultProxyHttpIconClass,
|
||||
vaultProxySocksIconClass,
|
||||
} from "./vault/VaultEntityIcon";
|
||||
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
|
||||
|
||||
interface ProxyProfilesManagerProps {
|
||||
proxyProfiles: ProxyProfile[];
|
||||
@@ -130,6 +132,7 @@ interface ProxyProfileCardProps {
|
||||
usageCount: number;
|
||||
viewMode: ProxyProfilesViewMode;
|
||||
isSelected: boolean;
|
||||
reorderProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
@@ -141,6 +144,7 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
|
||||
usageCount,
|
||||
viewMode,
|
||||
isSelected,
|
||||
reorderProps,
|
||||
onClick,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
@@ -158,14 +162,17 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<button
|
||||
{...reorderProps}
|
||||
type="button"
|
||||
aria-label={accessibleLabel}
|
||||
className={cn(
|
||||
reorderProps && "vault-drop-indicator-row",
|
||||
"group w-full text-left focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
viewMode === "grid"
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
isSelected && "ring-2 ring-primary",
|
||||
reorderProps?.className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
@@ -224,6 +231,7 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
viewMode === "list" ? "list" : "grid";
|
||||
const [draft, setDraft] = useState<ProxyProfile | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ProxyProfile | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const usageByProfileId = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
@@ -235,15 +243,26 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
|
||||
const filteredProfiles = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return proxyProfiles;
|
||||
return proxyProfiles.filter((profile) =>
|
||||
const result = !q ? proxyProfiles : proxyProfiles.filter((profile) =>
|
||||
profile.label.toLowerCase().includes(q) ||
|
||||
profile.config.host.toLowerCase().includes(q) ||
|
||||
(profile.config.command || "").toLowerCase().includes(q) ||
|
||||
profile.config.type.toLowerCase().includes(q),
|
||||
);
|
||||
return sortByVaultOrder(result);
|
||||
}, [proxyProfiles, search]);
|
||||
|
||||
const profileReorder = useVaultItemReorder({
|
||||
containerRef: listRef,
|
||||
viewMode: proxyProfilesViewMode,
|
||||
dragType: "proxy-profile-id",
|
||||
targetAttribute: "data-proxy-profile-id",
|
||||
disabled: search.trim().length > 0,
|
||||
onReorder: (sourceId, targetId, position) => {
|
||||
onUpdateProxyProfiles(reorderVaultItems(proxyProfiles, sourceId, targetId, position));
|
||||
},
|
||||
});
|
||||
|
||||
const updateDraftConfig = (field: keyof ProxyConfig, value: string | number) => {
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
@@ -397,7 +416,14 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
</div>
|
||||
</VaultPageHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div
|
||||
ref={listRef}
|
||||
className="flex-1 overflow-y-auto"
|
||||
onDragOverCapture={profileReorder.handleDragOverCapture}
|
||||
onDragOver={profileReorder.handleDragOver}
|
||||
onDropCapture={profileReorder.handleDropCapture}
|
||||
onDragEndCapture={profileReorder.handleDragEndCapture}
|
||||
>
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className={vaultSectionTitleClass}>
|
||||
@@ -439,6 +465,7 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
usageCount={usageByProfileId.get(profile.id) ?? 0}
|
||||
viewMode={proxyProfilesViewMode}
|
||||
isSelected={draft?.id === profile.id}
|
||||
reorderProps={profileReorder.getItemReorderProps(profile.id, `proxy:${profile.id}`)}
|
||||
onClick={() => openEdit(profile)}
|
||||
onEdit={() => openEdit(profile)}
|
||||
onDuplicate={() => duplicateProfile(profile)}
|
||||
|
||||
22
components/QuickAddSnippetDialog.test.tsx
Normal file
22
components/QuickAddSnippetDialog.test.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getQuickAddSnippetInitialCommand } from "./QuickAddSnippetDialog.tsx";
|
||||
|
||||
test("quick add snippet event can prefill command", () => {
|
||||
const event = {
|
||||
detail: { command: "ls -la\npwd" },
|
||||
} as CustomEvent<{ command?: string }>;
|
||||
|
||||
assert.equal(getQuickAddSnippetInitialCommand(event), "ls -la\npwd");
|
||||
});
|
||||
|
||||
test("quick add snippet event defaults to an empty command", () => {
|
||||
assert.equal(getQuickAddSnippetInitialCommand({} as Event), "");
|
||||
assert.equal(
|
||||
getQuickAddSnippetInitialCommand({
|
||||
detail: { command: 123 },
|
||||
} as unknown as Event),
|
||||
"",
|
||||
);
|
||||
});
|
||||
@@ -34,6 +34,11 @@ export interface QuickAddSnippetDialogProps {
|
||||
onCreatePackage?: (packagePath: string) => void;
|
||||
}
|
||||
|
||||
export function getQuickAddSnippetInitialCommand(event: Event): string {
|
||||
const detail = (event as CustomEvent<{ command?: unknown }>).detail;
|
||||
return typeof detail?.command === 'string' ? detail.command : '';
|
||||
}
|
||||
|
||||
export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
snippets,
|
||||
packages,
|
||||
@@ -54,10 +59,10 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
// terminal-side ScriptsSidePanel + button. We reset form state on
|
||||
// every open so stale input from a previous cancel does not leak.
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const handler = (event: Event) => {
|
||||
setEditing(null);
|
||||
setLabel('');
|
||||
setCommand('');
|
||||
setCommand(getQuickAddSnippetInitialCommand(event));
|
||||
setPackagePath('');
|
||||
setNoAutoRun(false);
|
||||
setOpen(true);
|
||||
|
||||
30
components/ScriptsSidePanel.test.ts
Normal file
30
components/ScriptsSidePanel.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildScriptsSidePanelRows } from "./ScriptsSidePanel.tsx";
|
||||
import type { Snippet } from "../types";
|
||||
|
||||
const snippet = (overrides: Partial<Snippet>): Snippet => ({
|
||||
id: overrides.id ?? "snippet",
|
||||
label: overrides.label ?? "Snippet",
|
||||
command: overrides.command ?? "echo ok",
|
||||
package: overrides.package ?? "",
|
||||
order: overrides.order,
|
||||
});
|
||||
|
||||
test("scripts side panel rows keep manual snippet order inside a package", () => {
|
||||
const rows = buildScriptsSidePanelRows({
|
||||
snippets: [
|
||||
snippet({ id: "alpha", label: "Alpha", package: "ops", order: 3000 }),
|
||||
snippet({ id: "zulu", label: "Zulu", package: "ops", order: 1000 }),
|
||||
snippet({ id: "beta", label: "Beta", package: "ops", order: 2000 }),
|
||||
],
|
||||
packages: ["ops"],
|
||||
expandedPaths: new Set(["ops"]),
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
rows.filter((row) => row.type === "snippet").map((row) => row.id),
|
||||
["zulu", "beta", "alpha"],
|
||||
);
|
||||
});
|
||||
@@ -10,6 +10,7 @@
|
||||
import { ChevronRight, Edit2, FileCode, Package, Plus, Search, Trash2, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from '../domain/vaultOrder';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Snippet } from '../types';
|
||||
import {
|
||||
@@ -33,6 +34,8 @@ interface ScriptsSidePanelProps {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
onSnippetClick: (snippet: Snippet) => void;
|
||||
onSnippetsChange?: (snippets: Snippet[]) => void;
|
||||
onPackagesChange?: (packages: string[]) => void;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
@@ -63,10 +66,163 @@ const pkgDisplayName = (path: string) => {
|
||||
return path.startsWith('/') && !clean.includes('/') ? `/${last}` : last;
|
||||
};
|
||||
|
||||
const packageDisplayIndex = (packages: string[], path: string): number => {
|
||||
const exactIndex = packages.indexOf(path);
|
||||
if (exactIndex >= 0) return exactIndex;
|
||||
const childIndex = packages.findIndex((pkg) => pkg.startsWith(`${path}/`));
|
||||
return childIndex >= 0 ? childIndex : Number.MAX_SAFE_INTEGER;
|
||||
};
|
||||
|
||||
let activeScriptsDropIndicator: HTMLElement | null = null;
|
||||
|
||||
const clearScriptsDropIndicator = () => {
|
||||
activeScriptsDropIndicator?.removeAttribute('data-vault-drop-position');
|
||||
activeScriptsDropIndicator = null;
|
||||
};
|
||||
|
||||
const markScriptsDropIndicator = (target: HTMLElement, position: 'before' | 'after') => {
|
||||
if (target.dataset.vaultDropPosition === position) return;
|
||||
clearScriptsDropIndicator();
|
||||
target.dataset.vaultDropPosition = position;
|
||||
activeScriptsDropIndicator = target;
|
||||
};
|
||||
|
||||
const markScriptsInsideIndicator = (target: HTMLElement) => {
|
||||
if (target.dataset.vaultDropPosition === 'inside') return;
|
||||
clearScriptsDropIndicator();
|
||||
target.dataset.vaultDropPosition = 'inside';
|
||||
activeScriptsDropIndicator = target;
|
||||
};
|
||||
|
||||
const getVerticalDropIntent = (
|
||||
element: HTMLElement,
|
||||
clientY: number,
|
||||
): 'before' | 'inside' | 'after' => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const edgeSize = Math.max(8, Math.min(14, rect.height * 0.28));
|
||||
if (clientY <= rect.top + edgeSize) return 'before';
|
||||
if (clientY >= rect.bottom - edgeSize) return 'after';
|
||||
return 'inside';
|
||||
};
|
||||
|
||||
const hasDragType = (dataTransfer: DataTransfer, type: string) =>
|
||||
Array.from(dataTransfer.types).includes(type);
|
||||
|
||||
export function buildScriptsSidePanelRows({
|
||||
snippets,
|
||||
packages,
|
||||
expandedPaths,
|
||||
}: {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
expandedPaths: Set<string>;
|
||||
}): TreeRow[] {
|
||||
const normalizedPackages = new Set<string>();
|
||||
const addWithAncestors = (raw: string) => {
|
||||
const path = raw.trim();
|
||||
if (!path) return;
|
||||
const isAbs = path.startsWith('/');
|
||||
const body = isAbs ? path.slice(1) : path;
|
||||
const parts = body.split('/').filter(Boolean);
|
||||
for (let i = 1; i <= parts.length; i += 1) {
|
||||
const sub = parts.slice(0, i).join('/');
|
||||
normalizedPackages.add(isAbs ? `/${sub}` : sub);
|
||||
}
|
||||
};
|
||||
|
||||
packages.forEach(addWithAncestors);
|
||||
snippets.forEach((snippet) => {
|
||||
if (snippet.package) addWithAncestors(snippet.package);
|
||||
});
|
||||
|
||||
const snippetsByPackage = new Map<string, Snippet[]>();
|
||||
const descendantCountByPackage = new Map<string, number>();
|
||||
const bumpCount = (path: string) => {
|
||||
descendantCountByPackage.set(path, (descendantCountByPackage.get(path) ?? 0) + 1);
|
||||
};
|
||||
|
||||
for (const snippet of snippets) {
|
||||
const pkg = snippet.package || '';
|
||||
const bucket = snippetsByPackage.get(pkg);
|
||||
if (bucket) bucket.push(snippet);
|
||||
else snippetsByPackage.set(pkg, [snippet]);
|
||||
|
||||
if (pkg === '') {
|
||||
bumpCount('');
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = pkg;
|
||||
while (true) {
|
||||
bumpCount(path);
|
||||
const slash = path.lastIndexOf('/');
|
||||
if (slash < 0) break;
|
||||
path = path.slice(0, slash);
|
||||
}
|
||||
}
|
||||
|
||||
const packagePaths = Array.from(normalizedPackages);
|
||||
const childPackagesOf = (parent: string | null): string[] => {
|
||||
const prefix = parent === null ? '' : `${parent}/`;
|
||||
return packagePaths
|
||||
.filter((path) => {
|
||||
if (parent === null) {
|
||||
const body = path.startsWith('/') ? path.slice(1) : path;
|
||||
return !body.includes('/');
|
||||
}
|
||||
if (!path.startsWith(prefix)) return false;
|
||||
const rest = path.slice(prefix.length);
|
||||
return rest.length > 0 && !rest.includes('/');
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const orderDiff = packageDisplayIndex(packages, a) - packageDisplayIndex(packages, b);
|
||||
if (orderDiff !== 0) return orderDiff;
|
||||
return pkgDisplayName(a).localeCompare(pkgDisplayName(b));
|
||||
});
|
||||
};
|
||||
|
||||
const snippetsIn = (pkg: string | null): Snippet[] =>
|
||||
sortByVaultOrder(snippetsByPackage.get(pkg ?? '') ?? []);
|
||||
|
||||
const rows: TreeRow[] = [];
|
||||
const walk = (pkg: string, depth: number) => {
|
||||
const children = childPackagesOf(pkg);
|
||||
const localSnippets = snippetsIn(pkg);
|
||||
const hasChildren = children.length > 0 || localSnippets.length > 0;
|
||||
const isExpanded = expandedPaths.has(pkg);
|
||||
|
||||
rows.push({
|
||||
type: 'package',
|
||||
id: pkg,
|
||||
path: pkg,
|
||||
name: pkgDisplayName(pkg),
|
||||
depth,
|
||||
count: descendantCountByPackage.get(pkg) ?? 0,
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
});
|
||||
|
||||
if (!isExpanded) return;
|
||||
children.forEach((child) => walk(child, depth + 1));
|
||||
localSnippets.forEach((snippet) =>
|
||||
rows.push({ type: 'snippet', id: snippet.id, depth: depth + 1, snippet, packagePath: pkg }),
|
||||
);
|
||||
};
|
||||
|
||||
snippetsIn(null).forEach((snippet) =>
|
||||
rows.push({ type: 'snippet', id: snippet.id, depth: 0, snippet, packagePath: '' }),
|
||||
);
|
||||
childPackagesOf(null).forEach((root) => walk(root, 0));
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
snippets,
|
||||
packages,
|
||||
onSnippetClick,
|
||||
onSnippetsChange,
|
||||
onPackagesChange,
|
||||
isVisible = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -126,47 +282,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
});
|
||||
}, [normalizedPackages, isVisible]);
|
||||
|
||||
const snippetIndex = useMemo(() => {
|
||||
if (!isVisible) {
|
||||
return {
|
||||
snippetsByPackage: new Map<string, Snippet[]>(),
|
||||
descendantCountByPackage: new Map<string, number>(),
|
||||
};
|
||||
}
|
||||
const snippetsByPackage = new Map<string, Snippet[]>();
|
||||
const descendantCountByPackage = new Map<string, number>();
|
||||
|
||||
const bumpCount = (path: string) => {
|
||||
descendantCountByPackage.set(path, (descendantCountByPackage.get(path) ?? 0) + 1);
|
||||
};
|
||||
|
||||
for (const snippet of snippets) {
|
||||
const pkg = snippet.package || '';
|
||||
const bucket = snippetsByPackage.get(pkg);
|
||||
if (bucket) bucket.push(snippet);
|
||||
else snippetsByPackage.set(pkg, [snippet]);
|
||||
|
||||
if (pkg === '') {
|
||||
bumpCount('');
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = pkg;
|
||||
while (true) {
|
||||
bumpCount(path);
|
||||
const slash = path.lastIndexOf('/');
|
||||
if (slash < 0) break;
|
||||
path = path.slice(0, slash);
|
||||
}
|
||||
}
|
||||
|
||||
for (const bucket of snippetsByPackage.values()) {
|
||||
bucket.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
return { snippetsByPackage, descendantCountByPackage };
|
||||
}, [snippets, isVisible]);
|
||||
|
||||
const togglePackage = useCallback((path: string) => {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -181,72 +296,19 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
if (!isVisible) return null;
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
return snippets.filter(
|
||||
return sortByVaultOrder(snippets.filter(
|
||||
(s) =>
|
||||
s.label.toLowerCase().includes(q) ||
|
||||
s.command.toLowerCase().includes(q),
|
||||
);
|
||||
));
|
||||
}, [snippets, search, isVisible]);
|
||||
|
||||
const rows = useMemo<TreeRow[]>(() => {
|
||||
if (!isVisible) return [];
|
||||
if (searchMatches !== null) return [];
|
||||
|
||||
const out: TreeRow[] = [];
|
||||
const paths: string[] = [];
|
||||
normalizedPackages.forEach((p) => paths.push(p));
|
||||
|
||||
const childPackagesOf = (parent: string | null): string[] => {
|
||||
const prefix = parent === null ? '' : parent + '/';
|
||||
return paths
|
||||
.filter((p) => {
|
||||
if (parent === null) {
|
||||
// Root-level: no "/" inside the body
|
||||
const body = p.startsWith('/') ? p.slice(1) : p;
|
||||
return !body.includes('/');
|
||||
}
|
||||
if (!p.startsWith(prefix)) return false;
|
||||
const rest = p.slice(prefix.length);
|
||||
return rest.length > 0 && !rest.includes('/');
|
||||
})
|
||||
.sort((a, b) => pkgDisplayName(a).localeCompare(pkgDisplayName(b)));
|
||||
};
|
||||
|
||||
const snippetsIn = (pkg: string | null): Snippet[] =>
|
||||
snippetIndex.snippetsByPackage.get(pkg ?? '') ?? [];
|
||||
|
||||
const walk = (pkg: string, depth: number) => {
|
||||
const children = childPackagesOf(pkg);
|
||||
const localSnippets = snippetsIn(pkg);
|
||||
const hasChildren = children.length > 0 || localSnippets.length > 0;
|
||||
const isExpanded = expandedPaths.has(pkg);
|
||||
|
||||
out.push({
|
||||
type: 'package',
|
||||
id: pkg,
|
||||
path: pkg,
|
||||
name: pkgDisplayName(pkg),
|
||||
depth,
|
||||
count: snippetIndex.descendantCountByPackage.get(pkg) ?? 0,
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
});
|
||||
|
||||
if (!isExpanded) return;
|
||||
children.forEach((c) => walk(c, depth + 1));
|
||||
localSnippets.forEach((s) =>
|
||||
out.push({ type: 'snippet', id: s.id, depth: depth + 1, snippet: s, packagePath: pkg }),
|
||||
);
|
||||
};
|
||||
|
||||
// Orphan / uncategorized snippets first (package === '')
|
||||
snippetsIn(null).forEach((s) =>
|
||||
out.push({ type: 'snippet', id: s.id, depth: 0, snippet: s, packagePath: '' }),
|
||||
);
|
||||
childPackagesOf(null).forEach((root) => walk(root, 0));
|
||||
|
||||
return out;
|
||||
}, [normalizedPackages, snippetIndex, expandedPaths, searchMatches, isVisible]);
|
||||
return buildScriptsSidePanelRows({ snippets, packages, expandedPaths });
|
||||
}, [snippets, packages, expandedPaths, searchMatches, isVisible]);
|
||||
|
||||
type ScriptsListItem =
|
||||
| { key: string; kind: 'search'; snippet: Snippet }
|
||||
@@ -286,6 +348,163 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
[onSnippetClick],
|
||||
);
|
||||
|
||||
const moveSnippetToPackage = useCallback((snippetId: string, packagePath: string | null) => {
|
||||
if (!onSnippetsChange) return;
|
||||
const targetPackage = packagePath || '';
|
||||
const snippet = snippets.find((item) => item.id === snippetId);
|
||||
if (!snippet || (snippet.package || '') === targetPackage) return;
|
||||
onSnippetsChange(snippets.map((item) =>
|
||||
item.id === snippetId ? { ...item, package: targetPackage } : item,
|
||||
));
|
||||
}, [onSnippetsChange, snippets]);
|
||||
|
||||
const movePackageToPackage = useCallback((source: string, target: string | null) => {
|
||||
if (!onPackagesChange || !onSnippetsChange) return;
|
||||
const name = source.split('/').pop() || '';
|
||||
const isAbsolute = source.startsWith('/');
|
||||
const newPath = target ? `${target}/${name}` : (isAbsolute ? `/${name}` : name);
|
||||
if (newPath === source || newPath.startsWith(`${source}/`) || packages.includes(newPath)) return;
|
||||
|
||||
const updatedPackages = packages.map((path) => {
|
||||
if (path === source) return newPath;
|
||||
if (path.startsWith(`${source}/`)) return newPath + path.substring(source.length);
|
||||
return path;
|
||||
});
|
||||
const updatedSnippets = snippets.map((snippet) => {
|
||||
const packagePath = snippet.package || '';
|
||||
if (packagePath === source) return { ...snippet, package: newPath };
|
||||
if (packagePath.startsWith(`${source}/`)) {
|
||||
return { ...snippet, package: newPath + packagePath.substring(source.length) };
|
||||
}
|
||||
return snippet;
|
||||
});
|
||||
|
||||
onPackagesChange(Array.from(new Set(updatedPackages)));
|
||||
onSnippetsChange(updatedSnippets);
|
||||
}, [onPackagesChange, onSnippetsChange, packages, snippets]);
|
||||
|
||||
const reorderSnippetToTarget = useCallback((
|
||||
sourceSnippetId: string,
|
||||
targetSnippetId: string,
|
||||
position: 'before' | 'after',
|
||||
) => {
|
||||
if (!onSnippetsChange || sourceSnippetId === targetSnippetId) return;
|
||||
const targetSnippet = snippets.find((snippet) => snippet.id === targetSnippetId);
|
||||
if (!targetSnippet) return;
|
||||
const movedSnippets = snippets.map((snippet) =>
|
||||
snippet.id === sourceSnippetId
|
||||
? { ...snippet, package: targetSnippet.package || '' }
|
||||
: snippet,
|
||||
);
|
||||
onSnippetsChange(reorderVaultItems(movedSnippets, sourceSnippetId, targetSnippetId, position));
|
||||
}, [onSnippetsChange, snippets]);
|
||||
|
||||
const reorderPackageToTarget = useCallback((
|
||||
sourcePackage: string,
|
||||
targetPackage: string,
|
||||
position: 'before' | 'after',
|
||||
) => {
|
||||
if (!onPackagesChange || sourcePackage === targetPackage) return;
|
||||
const parentOf = (path: string) => {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
const prefix = path.startsWith('/') ? '/' : '';
|
||||
return prefix + parts.slice(0, -1).join('/');
|
||||
};
|
||||
if (parentOf(sourcePackage) !== parentOf(targetPackage)) return;
|
||||
const sortablePackages = Array.from(new Set([...packages, sourcePackage, targetPackage]));
|
||||
onPackagesChange(reorderVaultStrings(sortablePackages, sourcePackage, targetPackage, position));
|
||||
}, [onPackagesChange, packages]);
|
||||
|
||||
const handleRowDragOver = useCallback((event: React.DragEvent<HTMLElement>) => {
|
||||
if (!onSnippetsChange && !onPackagesChange) return;
|
||||
const row = event.currentTarget;
|
||||
const targetSnippetId = row.getAttribute('data-snippet-id');
|
||||
const targetPackage = row.getAttribute('data-pkg-path');
|
||||
const isDraggingSnippet = hasDragType(event.dataTransfer, 'snippet-id');
|
||||
const isDraggingPackage = hasDragType(event.dataTransfer, 'pkg-path');
|
||||
if (targetSnippetId && isDraggingSnippet) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
const rect = row.getBoundingClientRect();
|
||||
markScriptsDropIndicator(row, event.clientY < rect.top + rect.height / 2 ? 'before' : 'after');
|
||||
return;
|
||||
}
|
||||
if (targetPackage && isDraggingSnippet) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
markScriptsInsideIndicator(row);
|
||||
return;
|
||||
}
|
||||
if (targetPackage && isDraggingPackage) {
|
||||
const sourcePackage = event.dataTransfer.getData('pkg-path');
|
||||
if (
|
||||
sourcePackage &&
|
||||
(sourcePackage === targetPackage || targetPackage.startsWith(`${sourcePackage}/`))
|
||||
) {
|
||||
event.dataTransfer.dropEffect = 'none';
|
||||
clearScriptsDropIndicator();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
const intent = getVerticalDropIntent(row, event.clientY);
|
||||
if (intent === 'inside') {
|
||||
markScriptsInsideIndicator(row);
|
||||
return;
|
||||
}
|
||||
markScriptsDropIndicator(row, intent);
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.dropEffect = 'none';
|
||||
clearScriptsDropIndicator();
|
||||
}, [onPackagesChange, onSnippetsChange]);
|
||||
|
||||
const handleRowDrop = useCallback((event: React.DragEvent<HTMLElement>) => {
|
||||
if (!onSnippetsChange && !onPackagesChange) return;
|
||||
const row = event.currentTarget;
|
||||
clearScriptsDropIndicator();
|
||||
|
||||
const targetSnippetId = row.getAttribute('data-snippet-id');
|
||||
const targetPackage = row.getAttribute('data-pkg-path');
|
||||
const sourceSnippetId = event.dataTransfer.getData('snippet-id');
|
||||
const sourcePackage = event.dataTransfer.getData('pkg-path');
|
||||
|
||||
if (sourceSnippetId && targetSnippetId) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const rect = row.getBoundingClientRect();
|
||||
reorderSnippetToTarget(
|
||||
sourceSnippetId,
|
||||
targetSnippetId,
|
||||
event.clientY < rect.top + rect.height / 2 ? 'before' : 'after',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sourceSnippetId && targetPackage) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
moveSnippetToPackage(sourceSnippetId, targetPackage);
|
||||
return;
|
||||
}
|
||||
if (sourcePackage && targetPackage) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const intent = getVerticalDropIntent(row, event.clientY);
|
||||
if (intent === 'inside') movePackageToPackage(sourcePackage, targetPackage);
|
||||
else reorderPackageToTarget(sourcePackage, targetPackage, intent);
|
||||
}
|
||||
}, [
|
||||
movePackageToPackage,
|
||||
moveSnippetToPackage,
|
||||
onPackagesChange,
|
||||
onSnippetsChange,
|
||||
reorderPackageToTarget,
|
||||
reorderSnippetToTarget,
|
||||
]);
|
||||
|
||||
const handleAddSnippet = useCallback(() => {
|
||||
// Let the App shell listen and navigate to the Snippets section with
|
||||
// the "add" panel pre-opened, so the user does not have to leave the
|
||||
@@ -366,6 +585,11 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
snippet={item.snippet}
|
||||
depth={0}
|
||||
subtitle={item.snippet.package || t('terminal.toolbar.library')}
|
||||
draggable={false}
|
||||
sortableTarget={false}
|
||||
onDragOver={handleRowDragOver}
|
||||
onDrop={handleRowDrop}
|
||||
onDragEnd={clearScriptsDropIndicator}
|
||||
onClick={() => handleSnippetClick(item.snippet)}
|
||||
onEdit={() => handleEditSnippet(item.snippet)}
|
||||
onDelete={() => handleDeleteSnippet(item.snippet.id)}
|
||||
@@ -379,6 +603,10 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
<PackageRow
|
||||
row={item.row}
|
||||
countLabel={item.countLabel}
|
||||
draggable={Boolean(onPackagesChange || onSnippetsChange)}
|
||||
onDragOver={handleRowDragOver}
|
||||
onDrop={handleRowDrop}
|
||||
onDragEnd={clearScriptsDropIndicator}
|
||||
onToggle={() => togglePackage(item.row.path)}
|
||||
/>
|
||||
);
|
||||
@@ -387,6 +615,11 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
<SnippetRow
|
||||
snippet={item.row.snippet}
|
||||
depth={item.row.depth}
|
||||
draggable={Boolean(onSnippetsChange)}
|
||||
sortableTarget={true}
|
||||
onDragOver={handleRowDragOver}
|
||||
onDrop={handleRowDrop}
|
||||
onDragEnd={clearScriptsDropIndicator}
|
||||
onClick={() => handleSnippetClick(item.row.snippet)}
|
||||
onEdit={() => handleEditSnippet(item.row.snippet)}
|
||||
onDelete={() => handleDeleteSnippet(item.row.snippet.id)}
|
||||
@@ -406,15 +639,29 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
interface PackageRowProps {
|
||||
row: Extract<TreeRow, { type: 'package' }>;
|
||||
countLabel: string;
|
||||
draggable: boolean;
|
||||
onDragOver: (event: React.DragEvent<HTMLElement>) => void;
|
||||
onDrop: (event: React.DragEvent<HTMLElement>) => void;
|
||||
onDragEnd: () => void;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const PackageRow = memo<PackageRowProps>(({ row, countLabel, onToggle }) => (
|
||||
const PackageRow = memo<PackageRowProps>(({ row, countLabel, draggable, onDragOver, onDrop, onDragEnd, onToggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors"
|
||||
className="vault-drop-indicator-row w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors"
|
||||
style={{ paddingLeft: 8 + row.depth * 14 }}
|
||||
data-pkg-path={row.path}
|
||||
draggable={draggable}
|
||||
onDragStart={(event) => {
|
||||
if (!draggable) return;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('pkg-path', row.path);
|
||||
}}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
@@ -435,6 +682,11 @@ interface SnippetRowProps {
|
||||
snippet: Snippet;
|
||||
depth: number;
|
||||
subtitle?: string;
|
||||
draggable: boolean;
|
||||
sortableTarget: boolean;
|
||||
onDragOver: (event: React.DragEvent<HTMLElement>) => void;
|
||||
onDrop: (event: React.DragEvent<HTMLElement>) => void;
|
||||
onDragEnd: () => void;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
@@ -446,6 +698,11 @@ const SnippetRow = memo<SnippetRowProps>(({
|
||||
snippet,
|
||||
depth,
|
||||
subtitle,
|
||||
draggable,
|
||||
sortableTarget,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
onClick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -454,7 +711,19 @@ const SnippetRow = memo<SnippetRowProps>(({
|
||||
}) => (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div>
|
||||
<div
|
||||
className="vault-drop-indicator-row"
|
||||
data-snippet-id={sortableTarget ? snippet.id : undefined}
|
||||
draggable={draggable}
|
||||
onDragStart={(event) => {
|
||||
if (!draggable) return;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('snippet-id', snippet.id);
|
||||
}}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -155,6 +155,8 @@ const SettingsAITabContainer: React.FC = () => {
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
quickMessages={aiState.quickMessages}
|
||||
setQuickMessages={aiState.setQuickMessages}
|
||||
/>
|
||||
</AITabErrorBoundary>
|
||||
);
|
||||
@@ -397,6 +399,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
<SettingsShortcutsTab
|
||||
hotkeyScheme={settings.hotkeyScheme}
|
||||
setHotkeyScheme={settings.setHotkeyScheme}
|
||||
shellOnlyTabNumberShortcuts={settings.shellOnlyTabNumberShortcuts}
|
||||
setShellOnlyTabNumberShortcuts={settings.setShellOnlyTabNumberShortcuts}
|
||||
keyBindings={settings.keyBindings}
|
||||
updateKeyBinding={settings.updateKeyBinding}
|
||||
resetKeyBinding={settings.resetKeyBinding}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE } from '../infrastructure/config/s
|
||||
import { cn, isMacPlatform } from '../lib/utils';
|
||||
import { Host, ProxyProfile, ShellHistoryEntry, Snippet, SSHKey } from '../types';
|
||||
import { HotkeyScheme, KeyBinding, keyEventToString, ManagedSource, matchesKeyBinding, parseKeyCombo } from '../domain/models';
|
||||
import { reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from '../domain/vaultOrder';
|
||||
import { Button } from './ui/button';
|
||||
import { ComboboxOption } from './ui/combobox';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
|
||||
@@ -26,6 +27,15 @@ import {
|
||||
vaultPrimaryIconClass,
|
||||
vaultSnippetIconClass,
|
||||
} from './vault/VaultEntityIcon';
|
||||
import {
|
||||
clearVaultDropIndicator as clearSnippetDropIndicator,
|
||||
getVaultDropIntent as getPackageDropIntent,
|
||||
getVaultDropPosition as getDropPosition,
|
||||
hasVaultDragType as hasDragType,
|
||||
markVaultDropIndicator as markSnippetDropIndicator,
|
||||
markVaultInsideDropIndicator as markSnippetInsideIndicator,
|
||||
useVaultGridLayoutAnimation,
|
||||
} from './vault/vaultReorderDrag';
|
||||
|
||||
interface SnippetsManagerProps {
|
||||
snippets: Snippet[];
|
||||
@@ -94,7 +104,14 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE,
|
||||
'grid',
|
||||
);
|
||||
const [sortMode, setSortMode] = useState<SortMode>('az');
|
||||
const [sortMode, setSortMode] = useState<SortMode>('manual');
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastPreviewReorderRef = useRef<string | null>(null);
|
||||
const draggingSnippetIdRef = useRef<string | null>(null);
|
||||
const draggingPackagePathRef = useRef<string | null>(null);
|
||||
const [draggingSnippetId, setDraggingSnippetId] = useState<string | null>(null);
|
||||
const [draggingPackagePath, setDraggingPackagePath] = useState<string | null>(null);
|
||||
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(listRef);
|
||||
|
||||
const [historyVisibleCount, setHistoryVisibleCount] = useState(HISTORY_PAGE_SIZE);
|
||||
const historyScrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -298,6 +315,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
targets: targetSelection,
|
||||
shortkey: editingSnippet.shortkey,
|
||||
noAutoRun: editingSnippet.noAutoRun,
|
||||
order: editingSnippet.order,
|
||||
});
|
||||
setRightPanelMode('none');
|
||||
}
|
||||
@@ -336,6 +354,23 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
};
|
||||
|
||||
const displayedPackages = useMemo(() => {
|
||||
const packageIndexByPath = new Map(packages.map((pkg, index) => [pkg, index]));
|
||||
const getPackageDisplayOrder = (path: string) => {
|
||||
const exactIndex = packageIndexByPath.get(path);
|
||||
if (typeof exactIndex === 'number') return exactIndex;
|
||||
const childIndex = packages.findIndex((pkg) => pkg.startsWith(path + '/'));
|
||||
return childIndex >= 0 ? childIndex : Number.MAX_SAFE_INTEGER;
|
||||
};
|
||||
const sortBySavedPackageOrder = (
|
||||
items: { name: string; path: string; count: number }[],
|
||||
) => {
|
||||
return [...items].sort((a, b) => {
|
||||
const orderDiff = getPackageDisplayOrder(a.path) - getPackageDisplayOrder(b.path);
|
||||
if (orderDiff !== 0) return orderDiff;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
};
|
||||
|
||||
if (!selectedPackage) {
|
||||
const absolutePaths = packages.filter(p => p.startsWith('/'));
|
||||
const relativePaths = packages.filter(p => !p.startsWith('/'));
|
||||
@@ -373,7 +408,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
results.push({ name: displayName, path, count });
|
||||
});
|
||||
|
||||
return results;
|
||||
return sortBySavedPackageOrder(results);
|
||||
}
|
||||
|
||||
const prefix = selectedPackage + '/';
|
||||
@@ -381,14 +416,14 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
.filter((p) => p.startsWith(prefix))
|
||||
.map((p) => p.replace(prefix, '').split('/')[0])
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
return Array.from(new Set(children)).map((name) => {
|
||||
return sortBySavedPackageOrder(Array.from(new Set<string>(children)).map((name) => {
|
||||
const path = `${selectedPackage}/${name}`;
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
return { name, path, count };
|
||||
});
|
||||
}));
|
||||
}, [packages, selectedPackage, snippets]);
|
||||
|
||||
const displayedSnippets = useMemo(() => {
|
||||
@@ -409,13 +444,17 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
return a.label.localeCompare(b.label);
|
||||
case 'za':
|
||||
return b.label.localeCompare(a.label);
|
||||
case 'manual':
|
||||
return 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
return sortMode === 'manual' ? sortByVaultOrder(result) : result;
|
||||
}, [snippets, selectedPackage, search, sortMode]);
|
||||
|
||||
const isSearchActive = search.trim().length > 0;
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
if (!selectedPackage) return [];
|
||||
const isAbsolute = selectedPackage.startsWith('/');
|
||||
@@ -591,6 +630,178 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
onSave({ ...sn, package: pkg || '' });
|
||||
};
|
||||
|
||||
const parentOfPackage = useCallback((path: string) => {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
const prefix = path.startsWith('/') ? '/' : '';
|
||||
return prefix + parts.slice(0, -1).join('/');
|
||||
}, []);
|
||||
|
||||
const resetSnippetDragState = useCallback(() => {
|
||||
clearSnippetDropIndicator();
|
||||
lastPreviewReorderRef.current = null;
|
||||
draggingSnippetIdRef.current = null;
|
||||
draggingPackagePathRef.current = null;
|
||||
setDraggingSnippetId(null);
|
||||
setDraggingPackagePath(null);
|
||||
}, []);
|
||||
|
||||
const handleReorderDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
const target = (event.target as Element | null)?.closest('[data-snippet-id], [data-pkg-path]');
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
|
||||
const isGrid = viewMode === 'grid';
|
||||
const targetSnippetId = target.getAttribute('data-snippet-id');
|
||||
const targetPackage = target.getAttribute('data-pkg-path');
|
||||
const isDraggingSnippet = Boolean(draggingSnippetIdRef.current) || hasDragType(event.dataTransfer, 'snippet-id');
|
||||
const isDraggingPackage = Boolean(draggingPackagePathRef.current) || hasDragType(event.dataTransfer, 'pkg-path');
|
||||
if (targetSnippetId && isDraggingSnippet) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
const sourceSnippetId = draggingSnippetIdRef.current || event.dataTransfer.getData('snippet-id');
|
||||
const position = getDropPosition(target, event.clientX, event.clientY, isGrid);
|
||||
if (isGrid && sourceSnippetId && sourceSnippetId !== targetSnippetId) {
|
||||
const targetSnippet = snippets.find((snippet) => snippet.id === targetSnippetId);
|
||||
const sourceSnippet = snippets.find((snippet) => snippet.id === sourceSnippetId);
|
||||
if (!targetSnippet || !sourceSnippet) return;
|
||||
const previewKey = `${sourceSnippetId}:${targetSnippetId}:${position}`;
|
||||
if (lastPreviewReorderRef.current === previewKey) return;
|
||||
prepareGridLayoutAnimation();
|
||||
lastPreviewReorderRef.current = previewKey;
|
||||
const movedSnippets = snippets.map((snippet) =>
|
||||
snippet.id === sourceSnippetId
|
||||
? { ...snippet, package: targetSnippet.package || '' }
|
||||
: snippet,
|
||||
);
|
||||
onBulkSave(reorderVaultItems(movedSnippets, sourceSnippetId, targetSnippetId, position));
|
||||
setSortMode('manual');
|
||||
return;
|
||||
}
|
||||
markSnippetDropIndicator(target, position, isGrid ? 'x' : 'y');
|
||||
return;
|
||||
}
|
||||
if (targetPackage && isDraggingSnippet) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
markSnippetInsideIndicator(target);
|
||||
return;
|
||||
}
|
||||
if (targetPackage && isDraggingPackage) {
|
||||
const sourcePackage = draggingPackagePathRef.current || event.dataTransfer.getData('pkg-path');
|
||||
if (
|
||||
sourcePackage &&
|
||||
targetPackage.startsWith(`${sourcePackage}/`)
|
||||
) {
|
||||
event.dataTransfer.dropEffect = 'none';
|
||||
clearSnippetDropIndicator();
|
||||
return;
|
||||
}
|
||||
const intent = getPackageDropIntent(target, event.clientX, event.clientY, isGrid);
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
if (intent === 'inside') {
|
||||
markSnippetInsideIndicator(target);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isGrid &&
|
||||
sourcePackage &&
|
||||
parentOfPackage(sourcePackage) === parentOfPackage(targetPackage)
|
||||
) {
|
||||
const previewKey = `package:${sourcePackage}:${targetPackage}:${intent}`;
|
||||
if (lastPreviewReorderRef.current !== previewKey) {
|
||||
prepareGridLayoutAnimation();
|
||||
lastPreviewReorderRef.current = previewKey;
|
||||
const sortablePackages = Array.from(new Set([...packages, sourcePackage, targetPackage]));
|
||||
onPackagesChange(reorderVaultStrings(sortablePackages, sourcePackage, targetPackage, intent));
|
||||
setSortMode('manual');
|
||||
}
|
||||
return;
|
||||
}
|
||||
markSnippetDropIndicator(target, intent, isGrid ? 'x' : 'y');
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.dropEffect = 'none';
|
||||
clearSnippetDropIndicator();
|
||||
}, [
|
||||
onBulkSave,
|
||||
onPackagesChange,
|
||||
packages,
|
||||
parentOfPackage,
|
||||
prepareGridLayoutAnimation,
|
||||
snippets,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const handleReorderDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
const target = (event.target as Element | null)?.closest('[data-snippet-id], [data-pkg-path]');
|
||||
clearSnippetDropIndicator();
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const isGrid = viewMode === 'grid';
|
||||
|
||||
const sourceSnippetId = draggingSnippetIdRef.current || event.dataTransfer.getData('snippet-id');
|
||||
const targetSnippetId = target.getAttribute('data-snippet-id');
|
||||
if (sourceSnippetId && targetSnippetId) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (sourceSnippetId === targetSnippetId) {
|
||||
lastPreviewReorderRef.current = null;
|
||||
return;
|
||||
}
|
||||
const targetSnippet = snippets.find((snippet) => snippet.id === targetSnippetId);
|
||||
const sourceSnippet = snippets.find((snippet) => snippet.id === sourceSnippetId);
|
||||
if (!targetSnippet || !sourceSnippet) return;
|
||||
const movedSnippets = snippets.map((snippet) =>
|
||||
snippet.id === sourceSnippetId
|
||||
? { ...snippet, package: targetSnippet.package || '' }
|
||||
: snippet,
|
||||
);
|
||||
const position = getDropPosition(target, event.clientX, event.clientY, isGrid);
|
||||
const previewKey = `${sourceSnippetId}:${targetSnippetId}:${position}`;
|
||||
if (!isGrid || lastPreviewReorderRef.current !== previewKey) {
|
||||
prepareGridLayoutAnimation();
|
||||
onBulkSave(reorderVaultItems(
|
||||
movedSnippets,
|
||||
sourceSnippetId,
|
||||
targetSnippetId,
|
||||
position,
|
||||
));
|
||||
}
|
||||
lastPreviewReorderRef.current = null;
|
||||
setSortMode('manual');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourcePackage = draggingPackagePathRef.current || event.dataTransfer.getData('pkg-path');
|
||||
const targetPackage = target.getAttribute('data-pkg-path');
|
||||
if (sourcePackage && targetPackage) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (sourcePackage === targetPackage) {
|
||||
lastPreviewReorderRef.current = null;
|
||||
return;
|
||||
}
|
||||
const intent = getPackageDropIntent(target, event.clientX, event.clientY, isGrid);
|
||||
if (intent === 'inside') return;
|
||||
if (parentOfPackage(sourcePackage) !== parentOfPackage(targetPackage)) return;
|
||||
const sortablePackages = Array.from(new Set([...packages, sourcePackage, targetPackage]));
|
||||
const previewKey = `package:${sourcePackage}:${targetPackage}:${intent}`;
|
||||
if (!isGrid || lastPreviewReorderRef.current !== previewKey) {
|
||||
prepareGridLayoutAnimation();
|
||||
onPackagesChange(reorderVaultStrings(sortablePackages, sourcePackage, targetPackage, intent));
|
||||
}
|
||||
lastPreviewReorderRef.current = null;
|
||||
setSortMode('manual');
|
||||
}
|
||||
}, [
|
||||
onBulkSave,
|
||||
onPackagesChange,
|
||||
packages,
|
||||
parentOfPackage,
|
||||
prepareGridLayoutAnimation,
|
||||
snippets,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const packageOptions: ComboboxOption[] = useMemo(() => {
|
||||
const allPaths = new Set<string>();
|
||||
|
||||
@@ -792,7 +1003,13 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-4 pb-4">
|
||||
<div
|
||||
ref={listRef}
|
||||
className="flex-1 space-y-3 overflow-y-auto px-4 pb-4"
|
||||
onDragOverCapture={handleReorderDragOver}
|
||||
onDropCapture={handleReorderDrop}
|
||||
onDragEndCapture={resetSnippetDragState}
|
||||
>
|
||||
{displayedPackages.length > 0 && !search.trim() && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -808,23 +1025,35 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer overflow-hidden",
|
||||
"vault-drop-indicator-row group cursor-pointer overflow-hidden",
|
||||
viewMode === 'grid'
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
|
||||
)}
|
||||
data-pkg-path={pkg.path}
|
||||
data-vault-grid-item={`snippet-package:${pkg.path}`}
|
||||
data-vault-reorder-grid={viewMode === 'grid' ? 'true' : undefined}
|
||||
data-vault-reorder-dragging={draggingPackagePath === pkg.path ? 'true' : undefined}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('pkg-path', pkg.path);
|
||||
draggingPackagePathRef.current = pkg.path;
|
||||
setDraggingPackagePath(pkg.path);
|
||||
lastPreviewReorderRef.current = null;
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const sId = e.dataTransfer.getData('snippet-id');
|
||||
const pPath = e.dataTransfer.getData('pkg-path');
|
||||
const sId = draggingSnippetIdRef.current || e.dataTransfer.getData('snippet-id');
|
||||
const pPath = draggingPackagePathRef.current || e.dataTransfer.getData('pkg-path');
|
||||
if (sId) moveSnippet(sId, pkg.path);
|
||||
if (pPath) movePackage(pPath, pkg.path);
|
||||
if (
|
||||
pPath &&
|
||||
getPackageDropIntent(e.currentTarget, e.clientX, e.clientY, viewMode === 'grid') === 'inside'
|
||||
) {
|
||||
movePackage(pPath, pkg.path);
|
||||
}
|
||||
}}
|
||||
onClick={() => setSelectedPackage(pkg.path)}
|
||||
>
|
||||
@@ -864,15 +1093,22 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer overflow-hidden",
|
||||
"vault-drop-indicator-row group cursor-pointer overflow-hidden",
|
||||
viewMode === 'grid'
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
|
||||
)}
|
||||
draggable
|
||||
data-snippet-id={isSearchActive ? undefined : snippet.id}
|
||||
data-vault-grid-item={`snippet:${snippet.id}`}
|
||||
data-vault-reorder-grid={viewMode === 'grid' ? 'true' : undefined}
|
||||
data-vault-reorder-dragging={draggingSnippetId === snippet.id ? 'true' : undefined}
|
||||
draggable={!isSearchActive}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('snippet-id', snippet.id);
|
||||
draggingSnippetIdRef.current = snippet.id;
|
||||
setDraggingSnippetId(snippet.id);
|
||||
lastPreviewReorderRef.current = null;
|
||||
}}
|
||||
onClick={() => handleEdit(snippet)}
|
||||
>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles } from "lucide-react";
|
||||
import { Activity, Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles } from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { detectLocalOs } from "../lib/localShell";
|
||||
@@ -91,6 +91,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
keys,
|
||||
identities,
|
||||
snippets,
|
||||
snippetPackages = [],
|
||||
compactToolbar = false,
|
||||
chainHosts = [],
|
||||
themePreviewId,
|
||||
knownHosts = [],
|
||||
@@ -131,7 +133,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onOpenSftp,
|
||||
onTerminalCwdChange,
|
||||
onOpenScripts,
|
||||
onOpenHistory,
|
||||
onOpenTheme,
|
||||
onOpenSystem,
|
||||
isBroadcastEnabled,
|
||||
onToggleBroadcast,
|
||||
onToggleComposeBar,
|
||||
@@ -145,6 +149,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}) => {
|
||||
const layoutSuppressActive = useTerminalLayoutSuppressActive();
|
||||
const deferTerminalResize = isResizing || layoutSuppressActive;
|
||||
const deferTerminalResizeRef = useRef(deferTerminalResize);
|
||||
deferTerminalResizeRef.current = deferTerminalResize;
|
||||
|
||||
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
|
||||
const CONNECTION_TIMEOUT = 120000;
|
||||
@@ -234,7 +240,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const sudoHintRef = useRef<((active: boolean) => boolean) | undefined>(undefined);
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { resizeSession, setSessionEncoding } = terminalBackend;
|
||||
const {
|
||||
resizeSession,
|
||||
selectFile,
|
||||
selectFileAvailable,
|
||||
sendSerialYmodem,
|
||||
serialYmodemAvailable,
|
||||
setSessionEncoding,
|
||||
} = terminalBackend;
|
||||
|
||||
|
||||
|
||||
@@ -448,6 +461,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
const isSerialConnection = host.protocol === "serial";
|
||||
const supportsRemoteImagePaste =
|
||||
!isLocalConnection &&
|
||||
!isSerialConnection &&
|
||||
host.protocol !== "telnet" &&
|
||||
host.protocol !== "mosh" &&
|
||||
!host.moshEnabled &&
|
||||
host.protocol !== "et" &&
|
||||
!host.etEnabled;
|
||||
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, never for
|
||||
// network devices. See isNetworkDevice above for why the gating uses the
|
||||
@@ -456,6 +477,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const isSupportedOs =
|
||||
!isNetworkDevice &&
|
||||
(host.os === 'linux' || host.os === 'macos' || detectedDeviceClass === 'linux-like');
|
||||
const isSystemSidebarEligible =
|
||||
!!onOpenSystem &&
|
||||
isSupportedOs &&
|
||||
!isLocalConnection &&
|
||||
!isSerialConnection &&
|
||||
host.protocol !== 'telnet';
|
||||
// Server-stats polling now lives inside <TerminalServerStats> (rendered by
|
||||
// TerminalView) so its ~5s refresh only re-renders that widget, not the whole
|
||||
// terminal. We just forward `isSupportedOs` via ctx.
|
||||
@@ -797,6 +824,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const broadcastUserPasteData = useCallback((data: string) => {
|
||||
if (sessionRef.current && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
||||
onBroadcastInputRef.current(data, sessionId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [sessionId]);
|
||||
|
||||
const executeSnippetCommand = useCallback((
|
||||
command: string,
|
||||
noAutoRun?: boolean,
|
||||
@@ -851,7 +886,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
terminalBackend,
|
||||
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
});
|
||||
// Kept fresh on every render so the mouseTracking capture handler at
|
||||
// handleContextMenuCapture (which is bound once per sessionId) can
|
||||
@@ -890,6 +928,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setShowSFTP(true);
|
||||
}, [host, onOpenSftp, resolveSftpInitialPath, sessionId, showSFTP]);
|
||||
|
||||
const handleSendYmodem = useCallback(async () => {
|
||||
if (!isSerialConnection || statusRef.current !== "connected") return;
|
||||
if (!selectFileAvailable() || !serialYmodemAvailable()) {
|
||||
toast.error(t("terminal.ymodem.unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = await selectFile(
|
||||
t("terminal.ymodem.selectFile"),
|
||||
undefined,
|
||||
[{ name: t("terminal.ymodem.allFiles"), extensions: ["*"] }],
|
||||
);
|
||||
if (!filePath) return;
|
||||
|
||||
const fileName = filePath.split(/[\\/]/).pop() || filePath;
|
||||
toast.info(t("terminal.ymodem.started", { fileName }));
|
||||
const result = await sendSerialYmodem(sessionRef.current || sessionId, filePath);
|
||||
if (result.success) {
|
||||
toast.success(t("terminal.ymodem.complete", { fileName: result.fileName || fileName }));
|
||||
} else {
|
||||
toast.error(result.error || t("terminal.ymodem.failed"));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("terminal.ymodem.failed"));
|
||||
}
|
||||
}, [isSerialConnection, selectFile, selectFileAvailable, sendSerialYmodem, serialYmodemAvailable, sessionId, t]);
|
||||
|
||||
const handleCancelConnect = () => {
|
||||
if (pendingHostKeyRequestId) {
|
||||
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
|
||||
@@ -1059,10 +1125,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
useTerminalFilePaste({
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
status,
|
||||
termRef,
|
||||
sessionRef,
|
||||
terminalBackend,
|
||||
resolveSftpInitialPath,
|
||||
scrollOnPasteRef,
|
||||
onPasteData: broadcastUserPasteData,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
containerRef,
|
||||
});
|
||||
@@ -1071,8 +1141,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<TerminalToolbar
|
||||
status={status}
|
||||
host={host}
|
||||
compactToolbar={compactToolbar}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
onSnippetClick={(snippet) => { void executeSnippet(snippet); }}
|
||||
onOpenSFTP={handleOpenSFTP}
|
||||
onSendYmodem={isSerialConnection ? handleSendYmodem : undefined}
|
||||
onOpenScripts={onOpenScripts ?? (() => {})}
|
||||
onOpenHistory={onOpenHistory}
|
||||
onOpenTheme={onOpenTheme ?? (() => {})}
|
||||
onUpdateHost={onUpdateHost}
|
||||
showClose={opts?.showClose}
|
||||
@@ -1085,20 +1161,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSetTerminalEncoding={handleSetTerminalEncoding}
|
||||
/>
|
||||
), [
|
||||
compactToolbar,
|
||||
executeSnippet,
|
||||
handleOpenSFTP,
|
||||
handleSendYmodem,
|
||||
handleSetTerminalEncoding,
|
||||
handleToggleSearch,
|
||||
host,
|
||||
inWorkspace,
|
||||
isSerialConnection,
|
||||
isComposeBarOpen,
|
||||
isSearchOpen,
|
||||
isWorkspaceComposeBarOpen,
|
||||
onCloseSession,
|
||||
onOpenScripts,
|
||||
onOpenHistory,
|
||||
onOpenTheme,
|
||||
onToggleComposeBar,
|
||||
onUpdateHost,
|
||||
sessionId,
|
||||
snippetPackages,
|
||||
snippets,
|
||||
status,
|
||||
terminalEncoding,
|
||||
]);
|
||||
@@ -1118,9 +1201,11 @@ 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, 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 });
|
||||
const effectiveComposeBarOpen = inWorkspace ? !!isWorkspaceComposeBarOpen : isComposeBarOpen;
|
||||
|
||||
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 }} />;
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, 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={{ Activity, 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, compactToolbar, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
|
||||
};
|
||||
|
||||
const Terminal = memo(TerminalComponent, terminalPropsAreEqual);
|
||||
|
||||
@@ -41,6 +41,8 @@ const baseProps = {
|
||||
onSetWorkspaceFocusedSession: () => {},
|
||||
isBroadcastEnabled: () => false,
|
||||
onToggleBroadcast: () => {},
|
||||
updateSnippets: () => {},
|
||||
updateSnippetPackages: () => {},
|
||||
onSplitSession: () => {},
|
||||
onConnectToHost: () => {},
|
||||
toggleScriptsSidePanelRef: { current: null },
|
||||
@@ -124,6 +126,24 @@ test("TerminalLayer re-renders when broadcast toggle handler changes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when snippet save handlers change", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, updateSnippets: () => {} } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, updateSnippetPackages: () => {} } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when SSH debug logging changes", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FolderTree, MessageSquare, PanelLeft, PanelRight, Palette, X, Zap } from 'lucide-react';
|
||||
import { FolderTree, History, MessageSquare, PanelLeft, PanelRight, Palette, X, Zap } from 'lucide-react';
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore } from '../application/state/activeTabStore';
|
||||
import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse';
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
shouldMarkSessionActivity,
|
||||
} from '../application/state/sessionActivity';
|
||||
import { sessionActivityStore } from '../application/state/sessionActivityStore';
|
||||
import { sessionCapabilitiesStore } from '../application/state/sessionCapabilitiesStore';
|
||||
import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
||||
import { collectSessionIds } from '../domain/workspace';
|
||||
|
||||
@@ -24,12 +25,15 @@ import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
|
||||
import type { DropEntry } from '../lib/sftpFileUtils';
|
||||
import { Host, KnownHost, TerminalSession, Workspace } from '../types';
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
|
||||
import { applySessionFontSizeToHost } from '../domain/terminalAppearance';
|
||||
import { resolveHostAutofillPassword } from '../domain/sshAuth';
|
||||
import { materializeHostProxyProfile } from '../domain/proxyProfiles';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { SftpSidePanel } from './SftpSidePanel';
|
||||
import { ScriptsSidePanel } from './ScriptsSidePanel';
|
||||
import { HistorySidePanel } from './HistorySidePanel';
|
||||
import { useRemoteHistoryState } from '../application/state/useRemoteHistoryState';
|
||||
import { resolveSnippetCommand } from './SnippetExecutionProvider';
|
||||
import type { Snippet } from '../types';
|
||||
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
|
||||
@@ -100,12 +104,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onUpdateTerminalFontFamilyId,
|
||||
onUpdateTerminalFontSize,
|
||||
onUpdateTerminalFontWeight,
|
||||
onUpdateSessionFontSize,
|
||||
onClearSessionFontSizeOverride,
|
||||
onCloseSession,
|
||||
onUpdateSessionStatus,
|
||||
onUpdateHostDistro,
|
||||
onUpdateHost,
|
||||
onAddKnownHost,
|
||||
onCommandExecuted,
|
||||
shellHistory = [],
|
||||
onTerminalDataCapture,
|
||||
onCreateWorkspaceFromSessions,
|
||||
onAddSessionToWorkspace,
|
||||
@@ -121,6 +128,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
isBroadcastEnabled,
|
||||
onToggleBroadcast,
|
||||
updateHosts,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
@@ -164,6 +173,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
// Stable callback references for Terminal components
|
||||
const handleCloseSession = useCallback((sessionId: string) => {
|
||||
sessionCapabilitiesStore.delete(sessionId);
|
||||
onCloseSession(sessionId);
|
||||
}, [onCloseSession]);
|
||||
|
||||
@@ -296,6 +306,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// Keep AI/scripts/theme panels mounted while switching sub-tabs (like SFTP).
|
||||
const [aiMountedTabIds, setAiMountedTabIds] = useState<string[]>([]);
|
||||
const [scriptsMountedTabIds, setScriptsMountedTabIds] = useState<string[]>([]);
|
||||
const [systemMountedTabIds, setSystemMountedTabIds] = useState<string[]>([]);
|
||||
const [themeMountedTabIds, setThemeMountedTabIds] = useState<string[]>([]);
|
||||
const [sidePanelWidth, setSidePanelWidth, persistSidePanelWidth] = useStoredNumber(
|
||||
STORAGE_KEY_SIDE_PANEL_WIDTH, 420, { min: 280, max: 800 },
|
||||
@@ -479,26 +490,28 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const moshEnabled = session.moshEnabled ?? existingHost.moshEnabled;
|
||||
const etEnabled = session.etEnabled ?? existingHost.etEnabled;
|
||||
|
||||
let hostForSession: Host;
|
||||
if (
|
||||
protocol === existingHost.protocol &&
|
||||
port === existingHost.port &&
|
||||
moshEnabled === existingHost.moshEnabled
|
||||
&& etEnabled === existingHost.etEnabled
|
||||
) {
|
||||
map.set(session.id, existingHost);
|
||||
hostForSession = existingHost;
|
||||
} else {
|
||||
map.set(session.id, {
|
||||
hostForSession = {
|
||||
...existingHost,
|
||||
protocol,
|
||||
port,
|
||||
moshEnabled,
|
||||
etEnabled,
|
||||
});
|
||||
};
|
||||
}
|
||||
map.set(session.id, applySessionFontSizeToHost(hostForSession, session));
|
||||
} else {
|
||||
// Create stable fallback host object
|
||||
const fallbackProtocol = session.protocol ?? 'local' as const;
|
||||
map.set(session.id, {
|
||||
const fallbackHost: Host = {
|
||||
id: session.hostId,
|
||||
label: session.hostLabel || 'Local Terminal',
|
||||
hostname: session.hostname || 'localhost',
|
||||
@@ -523,7 +536,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
});
|
||||
};
|
||||
map.set(session.id, applySessionFontSizeToHost(fallbackHost, session));
|
||||
}
|
||||
}
|
||||
return map;
|
||||
@@ -619,6 +633,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}, [hostMap, sessions, keys, identities]);
|
||||
|
||||
const handleTerminalFontSizeChange = useCallback((sessionId: string, nextFontSize: number) => {
|
||||
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
|
||||
// Workspace panes keep per-session font size so zooming one split does not
|
||||
// change global defaults or sibling panes (even when they share a host).
|
||||
if (session?.workspaceId) {
|
||||
onUpdateSessionFontSize?.(sessionId, nextFontSize);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionHost = sessionHostsMapRef.current.get(sessionId);
|
||||
if (!sessionHost) return;
|
||||
|
||||
@@ -630,7 +652,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
|
||||
onUpdateHost({ ...rawHost, fontSize: nextFontSize, fontSizeOverride: true });
|
||||
}, [onUpdateHost, onUpdateTerminalFontSize]);
|
||||
}, [onUpdateHost, onUpdateSessionFontSize, onUpdateTerminalFontSize]);
|
||||
|
||||
const validAIScopeTargetIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
@@ -678,6 +700,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
if (panel === 'theme') {
|
||||
setThemeMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
return;
|
||||
}
|
||||
if (panel === 'system') {
|
||||
setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -753,6 +779,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setAiMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
|
||||
setScriptsMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
|
||||
setThemeMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
|
||||
setSystemMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
|
||||
refocusTerminalSession(sessionIdToRefocus);
|
||||
}, [getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
|
||||
|
||||
@@ -866,12 +893,20 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleSwitchSidePanelTab('theme');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
const handleOpenHistory = useCallback(() => {
|
||||
handleSwitchSidePanelTab('history');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
// Open AI chat side panel (side-panel rail button: a plain switch that is a
|
||||
// no-op when AI is already the active sub-panel, matching the other rail tabs)
|
||||
const handleOpenAI = useCallback(() => {
|
||||
handleSwitchSidePanelTab('ai');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
const handleOpenSystem = useCallback(() => {
|
||||
handleSwitchSidePanelTab('system');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
const handleAddSelectionToAI = useCallback((sourceSessionId: string, selection: string) => {
|
||||
const text = selection.trim();
|
||||
if (!text) return;
|
||||
@@ -937,6 +972,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
textarea?.focus();
|
||||
}, [terminalBackend]);
|
||||
|
||||
const remoteHistory = useRemoteHistoryState();
|
||||
const handleHistoryPaste = useCallback(
|
||||
(command: string) => handleSnippetClickForFocusedSession(command, true),
|
||||
[handleSnippetClickForFocusedSession],
|
||||
);
|
||||
const handleHistoryRun = useCallback(
|
||||
(command: string) => handleSnippetClickForFocusedSession(command, false),
|
||||
[handleSnippetClickForFocusedSession],
|
||||
);
|
||||
|
||||
const handleSnippetFromPanel = useCallback(async (snippet: Snippet) => {
|
||||
const command = await resolveSnippetCommand(snippet);
|
||||
if (command === null) return;
|
||||
@@ -1000,6 +1045,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
clearTerminalPreviewVars,
|
||||
clearTopTabsPreviewVars,
|
||||
FolderTree,
|
||||
History,
|
||||
HistorySidePanel,
|
||||
MessageSquare,
|
||||
Palette,
|
||||
PanelLeft,
|
||||
@@ -1024,10 +1071,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleCommandExecuted,
|
||||
handleCommandSubmitted,
|
||||
handleComposeSend,
|
||||
handleHistoryPaste,
|
||||
handleHistoryRun,
|
||||
handleOpenHistory,
|
||||
handleOpenSftp,
|
||||
handleOpenScripts,
|
||||
handleOpenTheme,
|
||||
handleOpenAI,
|
||||
handleOpenSystem,
|
||||
handleOsDetected,
|
||||
handlePendingTerminalSelectionConsumed,
|
||||
handlePendingUploadHandled,
|
||||
@@ -1062,6 +1113,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
mountedAiTabIds: aiMountedTabIds,
|
||||
mountedSftpTabIds,
|
||||
scriptsMountedTabIds,
|
||||
systemMountedTabIds,
|
||||
themeMountedTabIds,
|
||||
onAddSessionToWorkspace,
|
||||
onConnectToHost,
|
||||
@@ -1083,10 +1135,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onUpdateTerminalFontFamilyId,
|
||||
onUpdateTerminalFontSize,
|
||||
onUpdateTerminalFontWeight,
|
||||
onUpdateSessionFontSize,
|
||||
onClearSessionFontSizeOverride,
|
||||
onUpdateTerminalThemeId,
|
||||
pendingTerminalSelectionForAI,
|
||||
refocusActiveTerminalSession,
|
||||
refocusTerminalSession,
|
||||
remoteHistory,
|
||||
shellHistory,
|
||||
resolveSftpHostForTab,
|
||||
ScriptsSidePanel,
|
||||
sessionActivityStore,
|
||||
@@ -1101,6 +1157,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setPendingTerminalSelectionForAI,
|
||||
setAiMountedTabIds,
|
||||
setScriptsMountedTabIds,
|
||||
setSystemMountedTabIds,
|
||||
setThemeMountedTabIds,
|
||||
setSidePanelOpenTabs,
|
||||
setSidePanelWidth,
|
||||
@@ -1145,6 +1202,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
updateHosts,
|
||||
updateSnippetPackages,
|
||||
updateSnippets,
|
||||
X,
|
||||
Zap,
|
||||
validAIScopeTargetIds,
|
||||
|
||||
358
components/TerminalPopupPage.tsx
Normal file
358
components/TerminalPopupPage.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import { Copy, Minus, Square, Unplug, X } from 'lucide-react';
|
||||
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { I18nProvider, useI18n } from '../application/i18n/I18nProvider';
|
||||
import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse';
|
||||
import { useSettingsState } from '../application/state/useSettingsState';
|
||||
import { useTerminalPopupWindow } from '../application/state/useTerminalPopupWindow';
|
||||
import { useVaultState } from '../application/state/useVaultState';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { shouldCloseTerminalPopupOnExit } from '../application/state/resolveTerminalSessionExitIntent';
|
||||
import type { TerminalPopupPayload } from '../domain/systemManager/types';
|
||||
import type { TerminalTheme } from '../domain/models';
|
||||
import type { Host } from '../types';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
const Terminal = lazy(() => import('./Terminal'));
|
||||
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
const POPUP_STARTUP_REVEAL_EXTRA_DELAY_MS = 900;
|
||||
const POPUP_STARTUP_REVEAL_MIN_DELAY_MS = 1500;
|
||||
const POPUP_STARTUP_REVEAL_MAX_DELAY_MS = 12000;
|
||||
|
||||
type PopupThemeVars = React.CSSProperties & Record<string, string>;
|
||||
|
||||
const buildPopupThemeVars = (theme: TerminalTheme): PopupThemeVars => {
|
||||
const { colors } = theme;
|
||||
return {
|
||||
'--terminal-popup-bg': colors.background,
|
||||
'--terminal-popup-fg': colors.foreground,
|
||||
'--terminal-popup-muted': colors.foreground,
|
||||
'--terminal-popup-accent': colors.cursor,
|
||||
'--terminal-popup-control-hover': `color-mix(in srgb, ${colors.foreground} 10%, transparent)`,
|
||||
};
|
||||
};
|
||||
|
||||
function TerminalPopupWindowControls({ mac, onClose }: { mac: boolean; onClose: () => void }) {
|
||||
const { minimize, maximize, isMaximized: fetchIsMaximized } = useWindowControls();
|
||||
const [isWindowMaximized, setIsWindowMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchIsMaximized().then((value) => {
|
||||
if (!cancelled) setIsWindowMaximized(!!value);
|
||||
});
|
||||
const handleResize = () => {
|
||||
void fetchIsMaximized().then((value) => setIsWindowMaximized(!!value));
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [fetchIsMaximized]);
|
||||
|
||||
const handleMaximize = async () => {
|
||||
const value = await maximize();
|
||||
setIsWindowMaximized(!!value);
|
||||
};
|
||||
|
||||
if (mac) return null;
|
||||
|
||||
const buttonClass =
|
||||
'app-no-drag flex h-10 w-11 items-center justify-center text-[color:var(--terminal-popup-muted)] transition-colors hover:bg-[color:var(--terminal-popup-control-hover)] hover:text-[color:var(--terminal-popup-fg)]';
|
||||
|
||||
return (
|
||||
<div className="app-no-drag ml-auto flex h-10 shrink-0 items-center">
|
||||
<button type="button" onClick={() => void minimize()} className={buttonClass} aria-label="Minimize">
|
||||
<Minus size={15} />
|
||||
</button>
|
||||
<button type="button" onClick={() => void handleMaximize()} className={buttonClass} aria-label={isWindowMaximized ? 'Restore' : 'Maximize'}>
|
||||
{isWindowMaximized ? <Copy size={14} /> : <Square size={13} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="app-no-drag flex h-10 w-11 items-center justify-center text-[color:var(--terminal-popup-fg)] opacity-80 transition-colors hover:bg-[color:var(--terminal-popup-control-hover)] hover:opacity-100"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalPopupSpinner() {
|
||||
return (
|
||||
<div className="h-full flex-1 flex items-center justify-center bg-[color:var(--terminal-popup-bg)] text-[color:var(--terminal-popup-fg)]">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 28 28"
|
||||
aria-label="Loading"
|
||||
className="opacity-80"
|
||||
>
|
||||
<circle
|
||||
cx="14"
|
||||
cy="14"
|
||||
r="11"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
opacity="0.18"
|
||||
/>
|
||||
<path
|
||||
d="M25 14a11 11 0 0 0-11-11"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
dur="0.75s"
|
||||
from="0 14 14"
|
||||
repeatCount="indefinite"
|
||||
to="360 14 14"
|
||||
type="rotate"
|
||||
/>
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalPopupBlank() {
|
||||
return (
|
||||
<div className="h-full flex-1 bg-[color:var(--terminal-popup-bg)]" />
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalPopupStartupError({
|
||||
message,
|
||||
closeLabel,
|
||||
onClose,
|
||||
}: {
|
||||
message: string;
|
||||
closeLabel: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center bg-[color:var(--terminal-popup-bg)] px-6 text-center text-[color:var(--terminal-popup-fg)]">
|
||||
<Unplug size={24} className="mb-3 opacity-45" />
|
||||
<div className="max-w-[300px] text-xs leading-5 opacity-70">{message}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="app-no-drag mt-4 h-7 rounded px-3 text-[11px] opacity-70 transition-colors hover:bg-[color:var(--terminal-popup-control-hover)] hover:opacity-100"
|
||||
>
|
||||
{closeLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalPopupTitleIcon({ icon }: { icon: TerminalPopupPayload['icon'] }) {
|
||||
if (!icon) return null;
|
||||
if (icon.kind !== 'image' || !icon.src) return null;
|
||||
return (
|
||||
<span
|
||||
className="pointer-events-none ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-[3px]"
|
||||
style={{
|
||||
backgroundColor: icon.backgroundColor ?? 'transparent',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={icon.src}
|
||||
alt={icon.alt ?? ''}
|
||||
width={11}
|
||||
height={11}
|
||||
className="max-h-[11px] max-w-[11px] rounded-[2px] object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fallback when the parent session's host is no longer in the vault (e.g. quick connect). */
|
||||
function buildHostFromSession(source: TerminalPopupPayload['sourceSession']): Host {
|
||||
return {
|
||||
id: source.hostId,
|
||||
label: source.hostLabel,
|
||||
hostname: source.hostname,
|
||||
username: source.username,
|
||||
port: source.port ?? (source.protocol === 'local' ? undefined : 22),
|
||||
protocol: source.protocol === 'local' ? 'local' : 'ssh',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
moshEnabled: source.moshEnabled,
|
||||
etEnabled: source.etEnabled,
|
||||
charset: source.charset,
|
||||
};
|
||||
}
|
||||
|
||||
function TerminalPopupPageInner() {
|
||||
const { t } = useI18n();
|
||||
const { close, setWindowTitle, onPopupConfig } = useTerminalPopupWindow();
|
||||
const { notifyRendererReady, onWindowCommandCloseRequested } = useWindowControls();
|
||||
const settings = useSettingsState();
|
||||
const { isInitialized: vaultInitialized, hosts, keys, identities, knownHosts, snippets, snippetPackages } = useVaultState();
|
||||
const [config, setConfig] = useState<TerminalPopupPayload | null>(null);
|
||||
const [terminalReady, setTerminalReady] = useState(false);
|
||||
const [startupError, setStartupError] = useState<string | null>(null);
|
||||
const sessionId = useMemo(() => crypto.randomUUID(), []);
|
||||
const popupThemeVars = useMemo(
|
||||
() => buildPopupThemeVars(settings.currentTerminalTheme),
|
||||
[settings.currentTerminalTheme],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onPopupConfig((payload) => {
|
||||
setConfig(payload);
|
||||
if (payload.title) {
|
||||
void setWindowTitle(payload.title);
|
||||
}
|
||||
});
|
||||
// Main delivers the popup payload as soon as the renderer reports ready
|
||||
// (and destroys the window if it never does) — so report ready only after
|
||||
// the config listener above is registered.
|
||||
notifyRendererReady();
|
||||
return unsubscribe;
|
||||
}, [notifyRendererReady, onPopupConfig, setWindowTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
return onWindowCommandCloseRequested(() => {
|
||||
void close();
|
||||
});
|
||||
}, [close, onWindowCommandCloseRequested]);
|
||||
|
||||
const host = useMemo(() => {
|
||||
if (!config) return null;
|
||||
const vaultHost = hosts.find((h) => h.id === config.sourceSession.hostId);
|
||||
return vaultHost ?? buildHostFromSession(config.sourceSession);
|
||||
}, [config, hosts]);
|
||||
|
||||
const reuseId = useMemo(() => {
|
||||
if (!config) return undefined;
|
||||
return canReuseTerminalConnection(config.sourceSession)
|
||||
? config.parentSessionId
|
||||
: undefined;
|
||||
}, [config]);
|
||||
|
||||
const ready = Boolean(config && host && vaultInitialized);
|
||||
const startupRevealDelayMs = useMemo(() => {
|
||||
if (!config?.startupCommand) return 0;
|
||||
const configuredDelay = settings.terminalSettings?.startupCommandDelayMs;
|
||||
const startupDelay = typeof configuredDelay === 'number' && Number.isFinite(configuredDelay)
|
||||
? Math.max(0, configuredDelay)
|
||||
: 600;
|
||||
return Math.min(
|
||||
POPUP_STARTUP_REVEAL_MAX_DELAY_MS,
|
||||
Math.max(POPUP_STARTUP_REVEAL_MIN_DELAY_MS, startupDelay + POPUP_STARTUP_REVEAL_EXTRA_DELAY_MS),
|
||||
);
|
||||
}, [config?.startupCommand, settings.terminalSettings?.startupCommandDelayMs]);
|
||||
const revealTerminal = useCallback(() => {
|
||||
setTerminalReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setTerminalReady(false);
|
||||
setStartupError(null);
|
||||
}, [config?.popupId, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return undefined;
|
||||
const timeout = window.setTimeout(() => setTerminalReady(true), startupRevealDelayMs);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [config?.popupId, ready, startupRevealDelayMs]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-screen flex flex-col overflow-hidden bg-[color:var(--terminal-popup-bg)] text-[color:var(--terminal-popup-fg)]"
|
||||
data-section="terminal-popup"
|
||||
style={popupThemeVars}
|
||||
>
|
||||
<div
|
||||
className="app-drag relative shrink-0 h-9 flex items-center bg-[color:var(--terminal-popup-bg)]"
|
||||
data-section="terminal-popup-titlebar"
|
||||
>
|
||||
{isMac && <div className="h-9 w-[92px] shrink-0" />}
|
||||
<TerminalPopupTitleIcon icon={config?.icon} />
|
||||
<div className={cn(
|
||||
'min-w-0 flex-1 pr-3 text-left text-[12px] font-medium text-[color:var(--terminal-popup-fg)] opacity-70',
|
||||
config?.icon ? 'pl-1.5' : 'pl-3',
|
||||
!isMac && 'pl-4 text-left',
|
||||
)}>
|
||||
<div className="max-w-full truncate">
|
||||
{config?.title ?? ''}
|
||||
</div>
|
||||
</div>
|
||||
{!isMac && <TerminalPopupWindowControls mac={false} onClose={() => void close()} />}
|
||||
</div>
|
||||
{!ready || !config || !host ? (
|
||||
<TerminalPopupSpinner />
|
||||
) : startupError ? (
|
||||
<TerminalPopupStartupError
|
||||
message={startupError}
|
||||
closeLabel={t('common.close')}
|
||||
onClose={() => void close()}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex-1 min-h-0 flex flex-col bg-[color:var(--terminal-popup-bg)]">
|
||||
<Suspense fallback={<TerminalPopupBlank />}>
|
||||
<Terminal
|
||||
host={host}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
compactToolbar
|
||||
knownHosts={knownHosts}
|
||||
isVisible
|
||||
isFocused
|
||||
fontFamilyId={settings.terminalFontFamilyId}
|
||||
fontSize={settings.terminalFontSize}
|
||||
terminalTheme={settings.currentTerminalTheme}
|
||||
followAppTerminalTheme={settings.followAppTerminalTheme}
|
||||
accentMode={settings.accentMode}
|
||||
customAccent={settings.customAccent}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
sessionId={sessionId}
|
||||
startupCommand={config.startupCommand}
|
||||
reuseConnectionFromSessionId={reuseId}
|
||||
onCloseSession={() => {
|
||||
void close();
|
||||
}}
|
||||
onSessionExit={(_closedSessionId, evt) => {
|
||||
if (shouldCloseTerminalPopupOnExit(evt)) {
|
||||
void close();
|
||||
return;
|
||||
}
|
||||
if (!terminalReady && config.startupCommand) {
|
||||
setStartupError(t('systemManager.popup.startupFailed'));
|
||||
}
|
||||
}}
|
||||
onStatusChange={(_changedSessionId, status) => {
|
||||
if (!config.startupCommand && status === 'connected') revealTerminal();
|
||||
}}
|
||||
onTerminalDataCapture={revealTerminal}
|
||||
/>
|
||||
</Suspense>
|
||||
{!terminalReady && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10">
|
||||
<TerminalPopupSpinner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TerminalPopupPage() {
|
||||
const settings = useSettingsState();
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<TerminalPopupPageInner />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
@@ -85,6 +85,14 @@ test("host tree toggle appears with opacity only and no bounce animation", () =>
|
||||
assert.doesNotMatch(toggleSlotCss, /scale/);
|
||||
});
|
||||
|
||||
test("host tree toggle exposes a custom CSS hook", () => {
|
||||
assert.match(topTabsSource, /data-section="top-tabs-host-tree-toggle"/);
|
||||
});
|
||||
|
||||
test("quick switcher plus button exposes a custom CSS hook", () => {
|
||||
assert.match(topTabsSource, /data-section="top-tabs-quick-switcher-toggle"/);
|
||||
});
|
||||
|
||||
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\);/);
|
||||
|
||||
@@ -812,6 +812,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<div
|
||||
ref={hostTreeToggleSlotRef}
|
||||
className="top-tab-host-tree-toggle-slot mb-0 flex-shrink-0 self-end app-no-drag"
|
||||
data-section="top-tabs-host-tree-toggle"
|
||||
data-visible={effectiveShowHostTreeToggle ? 'true' : 'false'}
|
||||
style={noDragRegionStyle}
|
||||
>
|
||||
@@ -879,6 +880,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-section="top-tabs-quick-switcher-toggle"
|
||||
className="h-7 w-7 flex-shrink-0 app-no-drag mb-0 rounded-none"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onOpenQuickSwitcher}
|
||||
|
||||
@@ -134,9 +134,18 @@ test("Hosts grouped sort mode is restored from storage", () => {
|
||||
|
||||
test("Hosts sort mode falls back safely when storage contains an invalid value", () => {
|
||||
const markup = renderVault("unknown-sort", [
|
||||
host("zulu", "Zulu Host", 2),
|
||||
host("alpha", "Alpha Host", 1),
|
||||
{ ...host("zulu", "Zulu Host", 2), order: 1000 },
|
||||
{ ...host("alpha", "Alpha Host", 1), order: 2000 },
|
||||
]);
|
||||
|
||||
assert.ok(markup.indexOf("Alpha Host") < markup.indexOf("Zulu Host"));
|
||||
assert.ok(markup.indexOf("Zulu Host") < markup.indexOf("Alpha Host"));
|
||||
});
|
||||
|
||||
test("Hosts manual sort mode uses saved order", () => {
|
||||
const markup = renderVault("manual", [
|
||||
{ ...host("alpha", "Alpha Host", 1), order: 2000 },
|
||||
{ ...host("zulu", "Zulu Host", 2), order: 1000 },
|
||||
]);
|
||||
|
||||
assert.ok(markup.indexOf("Zulu Host") < markup.indexOf("Alpha Host"));
|
||||
});
|
||||
|
||||
@@ -49,6 +49,11 @@ import {
|
||||
upsertHostById,
|
||||
} from "../domain/host";
|
||||
import { exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import {
|
||||
reorderVaultItems,
|
||||
reorderVaultStrings,
|
||||
type VaultOrderPosition,
|
||||
} from "../domain/vaultOrder";
|
||||
import {
|
||||
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
|
||||
STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED,
|
||||
@@ -125,12 +130,35 @@ const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
|
||||
|
||||
export type VaultSection = "hosts" | "keys" | "proxies" | "snippets" | "port" | "knownhosts" | "logs";
|
||||
|
||||
const haveSameHostOrderResult = (previous: Host[], next: Host[]) => {
|
||||
if (previous.length !== next.length) return false;
|
||||
return next.every((host, index) => {
|
||||
const current = previous[index];
|
||||
return (
|
||||
current?.id === host.id &&
|
||||
current.order === host.order &&
|
||||
current.group === host.group &&
|
||||
current.label === host.label &&
|
||||
current.managedSourceId === host.managedSourceId
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const haveSameGroupConfigs = (previous: GroupConfig[], next: GroupConfig[]) => {
|
||||
if (previous.length !== next.length) return false;
|
||||
return next.every((config, index) => {
|
||||
const current = previous[index];
|
||||
return current?.path === config.path && current.order === config.order;
|
||||
});
|
||||
};
|
||||
|
||||
const VAULT_SIDEBAR_MIN_WIDTH = 56;
|
||||
const VAULT_SIDEBAR_DEFAULT_WIDTH = 208;
|
||||
const VAULT_SIDEBAR_MAX_WIDTH = 320;
|
||||
const VAULT_SIDEBAR_LABEL_THRESHOLD = 132;
|
||||
|
||||
const isSortMode = (value: string): value is SortMode =>
|
||||
value === "manual" ||
|
||||
value === "az" ||
|
||||
value === "za" ||
|
||||
value === "newest" ||
|
||||
@@ -308,7 +336,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const treeExpandedState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
|
||||
const [sortMode, setSortMode] = useStoredString<SortMode>(
|
||||
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
|
||||
"az",
|
||||
"manual",
|
||||
isSortMode,
|
||||
);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
@@ -656,6 +684,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
visibleDisplayedHosts,
|
||||
} = useVaultHostCollections({
|
||||
customGroups,
|
||||
groupConfigs,
|
||||
hosts,
|
||||
knownHosts,
|
||||
onConvertKnownHost,
|
||||
@@ -926,6 +955,70 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
setSelectedGroupPath(newPath);
|
||||
}
|
||||
};
|
||||
|
||||
const reorderHost = useCallback((sourceHostId: string, targetHostId: string, position: VaultOrderPosition) => {
|
||||
const source = hostsRef.current.find((host) => host.id === sourceHostId);
|
||||
const target = hostsRef.current.find((host) => host.id === targetHostId);
|
||||
if (!source || !target) return;
|
||||
const targetGroup = target.group || "";
|
||||
const targetManagedSource = managedSources
|
||||
.filter((sourceInfo) => targetGroup === sourceInfo.groupName || targetGroup.startsWith(`${sourceInfo.groupName}/`))
|
||||
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
|
||||
const updatedHosts = hostsRef.current.map((host) =>
|
||||
host.id === sourceHostId
|
||||
? {
|
||||
...host,
|
||||
label:
|
||||
targetManagedSource && (!host.protocol || host.protocol === "ssh")
|
||||
? host.label.replace(/\s/g, "")
|
||||
: host.label,
|
||||
group: targetGroup,
|
||||
managedSourceId:
|
||||
targetManagedSource && (!host.protocol || host.protocol === "ssh")
|
||||
? targetManagedSource.id
|
||||
: undefined,
|
||||
}
|
||||
: host,
|
||||
);
|
||||
const reorderedHosts = reorderVaultItems(updatedHosts, sourceHostId, targetHostId, position);
|
||||
if (haveSameHostOrderResult(hostsRef.current, reorderedHosts)) return;
|
||||
onUpdateHosts(reorderedHosts);
|
||||
setSortMode("manual");
|
||||
}, [managedSources, onUpdateHosts, setSortMode]);
|
||||
|
||||
const reorderGroup = useCallback((sourcePath: string, targetPath: string, position: VaultOrderPosition) => {
|
||||
const parentOf = (path: string) => {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
return parts.slice(0, -1).join("/");
|
||||
};
|
||||
if (parentOf(sourcePath) !== parentOf(targetPath)) return false;
|
||||
const sortableGroups = Array.from(new Set([...customGroups, sourcePath, targetPath]));
|
||||
const updatedGroups = reorderVaultStrings(sortableGroups, sourcePath, targetPath, position);
|
||||
const orderByPath = new Map(updatedGroups.map((path, index) => [path, (index + 1) * 1000]));
|
||||
const configByPath = new Map<string, GroupConfig>(groupConfigs.map((config) => [config.path, config]));
|
||||
const nextConfigs: GroupConfig[] = [
|
||||
...updatedGroups.map((path) => {
|
||||
const existing = configByPath.get(path);
|
||||
const base: GroupConfig = existing ? { ...existing } : { path };
|
||||
return {
|
||||
...base,
|
||||
order: orderByPath.get(path),
|
||||
};
|
||||
}),
|
||||
...groupConfigs.filter((config) => !orderByPath.has(config.path)),
|
||||
];
|
||||
if (
|
||||
updatedGroups.length === customGroups.length &&
|
||||
updatedGroups.every((path, index) => path === customGroups[index]) &&
|
||||
haveSameGroupConfigs(groupConfigs, nextConfigs)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
onUpdateCustomGroups(updatedGroups);
|
||||
onUpdateGroupConfigs(nextConfigs);
|
||||
setSortMode("manual");
|
||||
return true;
|
||||
}, [customGroups, groupConfigs, onUpdateCustomGroups, onUpdateGroupConfigs, setSortMode]);
|
||||
const {
|
||||
getDropTargetClasses,
|
||||
handleUnmanageGroup,
|
||||
@@ -981,6 +1074,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
handleUnmanageGroup,
|
||||
moveHostToGroup,
|
||||
moveGroup,
|
||||
reorderHost,
|
||||
reorderGroup,
|
||||
managedGroupPaths,
|
||||
startInlineNewGroup,
|
||||
startInlineRenameGroup,
|
||||
@@ -1036,7 +1131,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onConfirmDelete={deleteGroupPath}
|
||||
/>
|
||||
<VaultViewLayout ctx={{ Activity, allGroupPaths, allTags, AppLogo, Array, Badge, BookMarked, Boolean, Button, CheckSquare, ChevronDown, cancelInlineGroupEdit, clearHostSelection, ClipboardCopy, Clock, cn, commitInlineGroupRename, connectionLogs, connectSelectedHosts, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, currentSection, customGroups, deleteGroupPath, deleteGroupWithHosts, deleteSelectedHosts, deleteTargetPath, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, displayedGroups, displayedHosts, DistroAvatar, Download, Dropdown, DropdownContent, DropdownTrigger, Edit2, editingGroupPath, editingHost, editingHostGroupDefaults, FileCode, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, Globe, groupConfigs, GroupDetailsPanel, groupedDisplayHosts, handleConnectClick, handleCopyCredentials, handleDeleteTag, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleEditTag, handleExportHosts, handleHostConnect, handleImportFileSelected, handleNewHost, handleProtocolSelect, handleQuickConnect, handleQuickConnectSaveHost, handleSaveGroupConfig, handleSearchKeyDown, handleUnmanageGroup, hasHostsSidePanel, HostDetailsPanel, hostListScrollRef, hosts, HostTreeView, hotkeyScheme, identities, ImportVaultDialog, Input, isDeleteGroupOpen, isGroupPanelOpen, isHostPanelOpen, isHostsSectionActive, isImportOpen, isMultiSelectMode, isNewFolderOpen, isQuickConnectOpen, isRenameGroupOpen, isSearchQuickConnect, isSerialModalOpen, Key, keyBindings, KeychainManager, keys, knownHostsManagerElement, Label, lastPinnedId, LayoutGrid, LazyConnectionLogsManager, LazyProtocolSelectDialog, List, managedGroupPaths, managedSources, moveGroup, moveHostToGroup, Network, newFolderName, newHostGroupPath, onClearUnsavedConnectionLogs, onConnectSerial, onCreateLocalTerminal, onDeleteConnectionLog, onDeleteHost, onImportOrReuseKey, onOpenLogView, onOpenSettings, onRunSnippet, onToggleConnectionLogSaved, onUpdateCustomGroups, onUpdateGroupConfigs, onUpdateHosts, onUpdateIdentities, onUpdateKeys, onUpdateProxyProfiles, onUpdateSnippetPackages, onUpdateSnippets, Pin, pinnedHosts, pinnedRecentIds, Plug, Plus, PortForwarding, protocolSelectHost, proxyProfiles, ProxyProfilesManager, quickConnectTarget, quickConnectWarnings, QuickConnectWizard, recentHosts, renameGroupError, renameGroupName, renameTargetPath, RippleButton, rootRef, sanitizeHost, search, Search, selectedGroupPath, selectedHostIds, selectedTags, SerialConnectModal, SerialHostDetailsPanel, sessionCount, Set, setCurrentSection, setDeleteGroupWithHosts, setDeleteTargetPath, setDragOverDropTarget, setEditingGroupPath, setEditingHost, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsGroupPanelOpen, setIsHostPanelOpen, setIsImportOpen, setIsMultiSelectMode, setIsNewFolderOpen, setIsQuickConnectOpen, setIsRenameGroupOpen, setIsSerialModalOpen, setLastPinnedId, setNewFolderName, setNewHostGroupPath, setProtocolSelectHost, setQuickConnectTarget, setQuickConnectWarnings, setRenameGroupError, setRenameGroupName, setRenameTargetPath, setSearch, setSelectedGroupPath, setSelectedHostIds, setSelectedTags, setSidebarCollapsed, setSidebarWidth, handleSidebarWidthCommit, setSortMode, setTargetParentPath, Settings, setViewMode, shellHistory, shouldHideEmptyRootHostsSection, showRecentHosts, sidebarCollapsed, sidebarWidth, snippetPackages, snippets, SnippetsManager, SortDropdown, sortMode, splitViewGridStyle, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, submitNewFolder, submitRenameGroup, Suspense, t, TagFilterDropdown, targetParentPath, terminalFontSize, terminalSettings, TerminalSquare, terminalThemeId, toggleHostPinned, toggleHostSelection, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, Upload, upsertHostById, Usb, viewMode, visibleDisplayedHosts, X, Zap }} />
|
||||
<VaultViewLayout ctx={{ Activity, allGroupPaths, allTags, AppLogo, Array, Badge, BookMarked, Boolean, Button, CheckSquare, ChevronDown, cancelInlineGroupEdit, clearHostSelection, ClipboardCopy, Clock, cn, commitInlineGroupRename, connectionLogs, connectSelectedHosts, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, currentSection, customGroups, deleteGroupPath, deleteGroupWithHosts, deleteSelectedHosts, deleteTargetPath, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, displayedGroups, displayedHosts, DistroAvatar, Download, Dropdown, DropdownContent, DropdownTrigger, Edit2, editingGroupPath, editingHost, editingHostGroupDefaults, FileCode, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, Globe, groupConfigs, GroupDetailsPanel, groupedDisplayHosts, handleConnectClick, handleCopyCredentials, handleDeleteTag, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleEditTag, handleExportHosts, handleHostConnect, handleImportFileSelected, handleNewHost, handleProtocolSelect, handleQuickConnect, handleQuickConnectSaveHost, handleSaveGroupConfig, handleSearchKeyDown, handleUnmanageGroup, hasHostsSidePanel, HostDetailsPanel, hostListScrollRef, hosts, HostTreeView, hotkeyScheme, identities, ImportVaultDialog, Input, isDeleteGroupOpen, isGroupPanelOpen, isHostPanelOpen, isHostsSectionActive, isImportOpen, isMultiSelectMode, isNewFolderOpen, isQuickConnectOpen, isRenameGroupOpen, isSearchQuickConnect, isSerialModalOpen, Key, keyBindings, KeychainManager, keys, knownHostsManagerElement, Label, lastPinnedId, LayoutGrid, LazyConnectionLogsManager, LazyProtocolSelectDialog, List, managedGroupPaths, managedSources, moveGroup, moveHostToGroup, Network, newFolderName, newHostGroupPath, onClearUnsavedConnectionLogs, onConnectSerial, onCreateLocalTerminal, onDeleteConnectionLog, onDeleteHost, onImportOrReuseKey, onOpenLogView, onOpenSettings, onRunSnippet, onToggleConnectionLogSaved, onUpdateCustomGroups, onUpdateGroupConfigs, onUpdateHosts, onUpdateIdentities, onUpdateKeys, onUpdateProxyProfiles, onUpdateSnippetPackages, onUpdateSnippets, Pin, pinnedHosts, pinnedRecentIds, Plug, Plus, PortForwarding, protocolSelectHost, proxyProfiles, ProxyProfilesManager, quickConnectTarget, quickConnectWarnings, QuickConnectWizard, recentHosts, renameGroupError, renameGroupName, renameTargetPath, reorderGroup, reorderHost, RippleButton, rootRef, sanitizeHost, search, Search, selectedGroupPath, selectedHostIds, selectedTags, SerialConnectModal, SerialHostDetailsPanel, sessionCount, Set, setCurrentSection, setDeleteGroupWithHosts, setDeleteTargetPath, setDragOverDropTarget, setEditingGroupPath, setEditingHost, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsGroupPanelOpen, setIsHostPanelOpen, setIsImportOpen, setIsMultiSelectMode, setIsNewFolderOpen, setIsQuickConnectOpen, setIsRenameGroupOpen, setIsSerialModalOpen, setLastPinnedId, setNewFolderName, setNewHostGroupPath, setProtocolSelectHost, setQuickConnectTarget, setQuickConnectWarnings, setRenameGroupError, setRenameGroupName, setRenameTargetPath, setSearch, setSelectedGroupPath, setSelectedHostIds, setSelectedTags, setSidebarCollapsed, setSidebarWidth, handleSidebarWidthCommit, setSortMode, setTargetParentPath, Settings, setViewMode, shellHistory, shouldHideEmptyRootHostsSection, showRecentHosts, sidebarCollapsed, sidebarWidth, snippetPackages, snippets, SnippetsManager, SortDropdown, sortMode, splitViewGridStyle, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, submitNewFolder, submitRenameGroup, Suspense, t, TagFilterDropdown, targetParentPath, terminalFontSize, terminalSettings, TerminalSquare, terminalThemeId, toggleHostPinned, toggleHostSelection, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, Upload, upsertHostById, Usb, viewMode, visibleDisplayedHosts, X, Zap }} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
10
components/VaultViewLayout.test.ts
Normal file
10
components/VaultViewLayout.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const vaultViewLayoutSource = readFileSync(new URL("./vault/VaultViewLayout.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("vault stage aligns its content to the top tab bar", () => {
|
||||
assert.match(vaultViewLayoutSource, /className="flex min-w-0 flex-1 py-0 pr-2 pb-2 pl-0"/);
|
||||
assert.doesNotMatch(vaultViewLayoutSource, /className="flex min-w-0 flex-1 p-2 pl-0"/);
|
||||
});
|
||||
@@ -12,6 +12,7 @@ type AgentLike = {
|
||||
type AgentIconKey =
|
||||
| 'catty'
|
||||
| 'copilot'
|
||||
| 'cursor'
|
||||
| 'openai'
|
||||
| 'claude'
|
||||
| 'anthropic'
|
||||
@@ -21,6 +22,7 @@ type AgentIconKey =
|
||||
| 'openrouter'
|
||||
| 'zed'
|
||||
| 'atom'
|
||||
| 'codebuddy'
|
||||
| 'terminal'
|
||||
| 'plus';
|
||||
|
||||
@@ -41,6 +43,11 @@ const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
|
||||
badgeClassName: 'border-zinc-300 bg-white',
|
||||
imageClassName: 'object-contain brightness-0',
|
||||
},
|
||||
cursor: {
|
||||
src: '/ai/agents/cursor.svg',
|
||||
badgeClassName: 'border-zinc-500/22 bg-zinc-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
|
||||
},
|
||||
openai: {
|
||||
src: '/ai/providers/openai.svg',
|
||||
badgeClassName: 'border-emerald-500/22 bg-emerald-500/12',
|
||||
@@ -86,6 +93,11 @@ const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
|
||||
badgeClassName: 'border-amber-500/18 bg-amber-500/10',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
|
||||
},
|
||||
codebuddy: {
|
||||
src: '/ai/agents/codebuddy.svg',
|
||||
badgeClassName: 'border-indigo-500/22 bg-indigo-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
|
||||
},
|
||||
terminal: {
|
||||
src: '/ai/agents/terminal.svg',
|
||||
badgeClassName: 'border-white/8 bg-white/[0.04]',
|
||||
@@ -124,6 +136,9 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
|
||||
if (tokens.some((token) => token.includes('copilot'))) {
|
||||
return 'copilot';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('cursor'))) {
|
||||
return 'cursor';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('anthropic'))) {
|
||||
return 'anthropic';
|
||||
}
|
||||
@@ -159,6 +174,9 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
|
||||
if (tokens.some((token) => token.includes('factory'))) {
|
||||
return 'atom';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('codebuddy'))) {
|
||||
return 'codebuddy';
|
||||
}
|
||||
|
||||
return 'terminal';
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* and a bottom toolbar with muted controls + subtle send button.
|
||||
*/
|
||||
|
||||
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
|
||||
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
|
||||
import { filterQuickMessages, buildSlashCommandItems, filterUserSkillsForSlash, getSlashCommandItemKey, type AIQuickMessage, type SlashCommandItem, type UserSkillSlashOption } from '../../infrastructure/ai/quickMessages';
|
||||
import { SlashCommandPicker } from './SlashCommandPicker';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { createPortal } from 'react-dom';
|
||||
@@ -81,6 +83,8 @@ interface ChatInputProps {
|
||||
selectedUserSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
|
||||
/** Available user skills for /skill-slug insertion */
|
||||
userSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
|
||||
/** Custom slash prompts configured in Settings → AI */
|
||||
quickMessages?: AIQuickMessage[];
|
||||
/** Callback to add a selected user skill */
|
||||
onAddUserSkill?: (slug: string) => void;
|
||||
/** Callback to remove a selected user skill */
|
||||
@@ -118,6 +122,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
hosts = [],
|
||||
selectedUserSkills = [],
|
||||
userSkills = [],
|
||||
quickMessages = [],
|
||||
onAddUserSkill,
|
||||
onRemoveUserSkill,
|
||||
permissionMode,
|
||||
@@ -128,7 +133,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
|
||||
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'slashSkill' | 'perm' | null;
|
||||
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'slashCommand' | 'perm' | null;
|
||||
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
|
||||
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
|
||||
const [inputPanelPos, setInputPanelPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
|
||||
@@ -142,7 +147,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const showModelPicker = activeMenu === 'model';
|
||||
const showAttachMenu = activeMenu === 'attach';
|
||||
const showAtMention = activeMenu === 'atMention';
|
||||
const showSlashSkillPicker = activeMenu === 'slashSkill';
|
||||
const showSlashCommandPicker = activeMenu === 'slashCommand';
|
||||
const showPermPicker = activeMenu === 'perm';
|
||||
|
||||
const closeAllMenus = useCallback(() => {
|
||||
@@ -158,6 +163,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const modelBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const permBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const attachBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const quickMsgBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const slashPickerListRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const findSlashTrigger = useCallback((text: string, caretPosition: number) => {
|
||||
@@ -202,21 +209,24 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
}
|
||||
|
||||
const slashTrigger = findSlashTrigger(newValue, caretPosition);
|
||||
if (userSkills.length > 0 && slashTrigger) {
|
||||
if (slashTrigger) {
|
||||
const pos = getInputPanelMenuPos();
|
||||
if (pos) setInputPanelPos(pos);
|
||||
if (pos) {
|
||||
setMenuPos(null);
|
||||
setInputPanelPos(pos);
|
||||
}
|
||||
setSlashQuery(slashTrigger.query);
|
||||
setSlashRange({ start: slashTrigger.start, end: slashTrigger.end });
|
||||
setActiveMenu('slashSkill');
|
||||
setActiveMenu('slashCommand');
|
||||
return;
|
||||
}
|
||||
|
||||
if (showAtMention && !newValue.includes('@')) {
|
||||
setActiveMenu(null);
|
||||
} else if (showSlashSkillPicker) {
|
||||
} else if (showSlashCommandPicker) {
|
||||
closeAllMenus();
|
||||
}
|
||||
}, [onChange, value, hosts.length, showAtMention, findSlashTrigger, userSkills.length, showSlashSkillPicker, closeAllMenus, getInputPanelMenuPos]);
|
||||
}, [onChange, value, hosts.length, showAtMention, findSlashTrigger, showSlashCommandPicker, closeAllMenus, getInputPanelMenuPos]);
|
||||
|
||||
const handleSelectAtMention = useCallback((host: { label: string; hostname: string }) => {
|
||||
// Replace the trailing @ with @hostname
|
||||
@@ -229,22 +239,84 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
closeAllMenus();
|
||||
}, [value, onChange, closeAllMenus]);
|
||||
|
||||
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashSkill') => {
|
||||
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashCommand') => {
|
||||
const pos = getInputPanelMenuPos();
|
||||
if (!pos) return;
|
||||
setMenuPos(null);
|
||||
setInputPanelPos(pos);
|
||||
if (menu === 'slashSkill') {
|
||||
setSlashQuery('');
|
||||
setSlashRange(null);
|
||||
if (menu === 'slashCommand') {
|
||||
const caret = textareaRef.current?.selectionStart ?? value.length;
|
||||
const trigger = findSlashTrigger(value, caret);
|
||||
if (trigger) {
|
||||
setSlashQuery(trigger.query);
|
||||
setSlashRange({ start: trigger.start, end: trigger.end });
|
||||
} else {
|
||||
setSlashQuery('');
|
||||
setSlashRange(null);
|
||||
}
|
||||
}
|
||||
setActiveMenu(menu);
|
||||
}, [getInputPanelMenuPos]);
|
||||
}, [findSlashTrigger, getInputPanelMenuPos, value]);
|
||||
|
||||
const filteredUserSkills = useMemo(() => userSkills.filter((skill) => {
|
||||
if (!slashQuery) return true;
|
||||
const lowerQuery = slashQuery.toLowerCase();
|
||||
return skill.slug.toLowerCase().startsWith(lowerQuery) || skill.name.toLowerCase().includes(lowerQuery);
|
||||
}), [userSkills, slashQuery]);
|
||||
const openSlashCommandPicker = useCallback((anchor?: 'toolbar') => {
|
||||
if (anchor === 'toolbar') {
|
||||
const rect = quickMsgBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
}
|
||||
setInputPanelPos(null);
|
||||
const caret = textareaRef.current?.selectionStart ?? value.length;
|
||||
const trigger = findSlashTrigger(value, caret);
|
||||
if (trigger) {
|
||||
setSlashQuery(trigger.query);
|
||||
setSlashRange({ start: trigger.start, end: trigger.end });
|
||||
} else {
|
||||
setSlashQuery('');
|
||||
setSlashRange(null);
|
||||
}
|
||||
setActiveMenu('slashCommand');
|
||||
return;
|
||||
}
|
||||
openInputPanelMenu('slashCommand');
|
||||
}, [findSlashTrigger, openInputPanelMenu, value]);
|
||||
|
||||
const userSkillOptions = useMemo<UserSkillSlashOption[]>(
|
||||
() => userSkills.map((skill) => ({
|
||||
id: skill.id,
|
||||
slug: skill.slug,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
})),
|
||||
[userSkills],
|
||||
);
|
||||
|
||||
const quickMessageSlugSet = useMemo(
|
||||
() => new Set(quickMessages.map((message) => message.slug)),
|
||||
[quickMessages],
|
||||
);
|
||||
|
||||
const filteredQuickMessages = useMemo(
|
||||
() => filterQuickMessages(quickMessages, slashQuery),
|
||||
[quickMessages, slashQuery],
|
||||
);
|
||||
|
||||
const filteredUserSkills = useMemo(
|
||||
() => filterUserSkillsForSlash(userSkillOptions, slashQuery)
|
||||
.filter((skill) => !quickMessageSlugSet.has(skill.slug)),
|
||||
[userSkillOptions, slashQuery, quickMessageSlugSet],
|
||||
);
|
||||
|
||||
const slashCommandItems = useMemo(
|
||||
() => buildSlashCommandItems(quickMessages, userSkillOptions, slashQuery),
|
||||
[quickMessages, userSkillOptions, slashQuery],
|
||||
);
|
||||
|
||||
const isSlashCatalogEmpty = quickMessages.length === 0 && userSkills.length === 0;
|
||||
const slashPickerNoResultsLabel = isSlashCatalogEmpty
|
||||
? t('ai.chat.slashEmptyHint')
|
||||
: t('ai.chat.slashNoResults');
|
||||
const slashPickerListboxId = menuPos ? 'slash-command-toolbar' : 'slash-command-input';
|
||||
const showSlashPickerUI = showSlashCommandPicker && (inputPanelPos != null || menuPos != null);
|
||||
|
||||
const removeSlashQueryFromInput = useCallback(() => {
|
||||
if (!slashRange) return value;
|
||||
@@ -264,6 +336,28 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
closeAllMenus();
|
||||
}, [closeAllMenus, onAddUserSkill, onChange, removeSlashQueryFromInput, slashRange]);
|
||||
|
||||
const insertQuickMessage = useCallback((message: AIQuickMessage) => {
|
||||
if (slashRange) {
|
||||
const before = value.slice(0, slashRange.start);
|
||||
const after = value.slice(slashRange.end);
|
||||
const spacerBefore = before.length > 0 && !/\s$/.test(before) ? ' ' : '';
|
||||
const spacerAfter = after.length > 0 && !/^\s/.test(after) ? ' ' : '';
|
||||
onChange(`${before}${spacerBefore}${message.content}${spacerAfter}${after}`);
|
||||
} else {
|
||||
const spacer = value.length > 0 && !/\s$/.test(value) ? ' ' : '';
|
||||
onChange(`${value}${spacer}${message.content}`);
|
||||
}
|
||||
closeAllMenus();
|
||||
}, [closeAllMenus, onChange, slashRange, value]);
|
||||
|
||||
const handleSelectSlashCommandItem = useCallback((item: SlashCommandItem) => {
|
||||
if (item.kind === 'quickMessage') {
|
||||
insertQuickMessage(item.message);
|
||||
return;
|
||||
}
|
||||
insertUserSkillToken(item.skill);
|
||||
}, [insertQuickMessage, insertUserSkillToken]);
|
||||
|
||||
// Reset active highlight when a menu opens or when the *identity* of the
|
||||
// visible items changes. Watching only `.length` misses cases where the
|
||||
// filter produces a different set with the same count (e.g. user types
|
||||
@@ -273,16 +367,64 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
() => hosts.map((h) => h.sessionId).join('|'),
|
||||
[hosts],
|
||||
);
|
||||
const slashSkillKey = useMemo(
|
||||
() => filteredUserSkills.map((s) => s.id).join('|'),
|
||||
[filteredUserSkills],
|
||||
const slashCommandKey = useMemo(
|
||||
() => slashCommandItems.map(getSlashCommandItemKey).join('|'),
|
||||
[slashCommandItems],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (showAtMention) setActiveMenuIndex(0);
|
||||
}, [showAtMention, atMentionKey]);
|
||||
useEffect(() => {
|
||||
if (showSlashSkillPicker) setActiveMenuIndex(0);
|
||||
}, [showSlashSkillPicker, slashSkillKey]);
|
||||
if (showSlashCommandPicker) setActiveMenuIndex(0);
|
||||
}, [showSlashCommandPicker, slashCommandKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSlashCommandPicker || !menuPos || slashCommandItems.length === 0) return;
|
||||
slashPickerListRef.current?.focus();
|
||||
}, [showSlashCommandPicker, menuPos, slashCommandKey, slashCommandItems.length]);
|
||||
|
||||
const handleSlashCommandKeyDown = useCallback((e: KeyboardEvent | React.KeyboardEvent) => {
|
||||
if ('nativeEvent' in e && e.nativeEvent.isComposing) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAllMenus();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
if ('shiftKey' in e && e.shiftKey) {
|
||||
return;
|
||||
}
|
||||
if (slashCommandItems.length > 0) {
|
||||
e.preventDefault();
|
||||
const item = slashCommandItems[Math.min(activeMenuIndex, slashCommandItems.length - 1)];
|
||||
if (item) handleSelectSlashCommandItem(item);
|
||||
return;
|
||||
}
|
||||
// Mid-slash token with no matches: block accidental send of "/query" text.
|
||||
if (slashRange) {
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (slashCommandItems.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i + 1) % slashCommandItems.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i - 1 + slashCommandItems.length) % slashCommandItems.length);
|
||||
return;
|
||||
}
|
||||
}, [activeMenuIndex, closeAllMenus, handleSelectSlashCommandItem, slashCommandItems, slashRange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSlashCommandPicker || !menuPos) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => handleSlashCommandKeyDown(event);
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true);
|
||||
}, [handleSlashCommandKeyDown, menuPos, showSlashCommandPicker]);
|
||||
|
||||
const handleTextareaKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
@@ -310,31 +452,12 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
return;
|
||||
}
|
||||
}
|
||||
// / skill popover keyboard navigation
|
||||
if (showSlashSkillPicker && filteredUserSkills.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i + 1) % filteredUserSkills.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i - 1 + filteredUserSkills.length) % filteredUserSkills.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const skill = filteredUserSkills[Math.min(activeMenuIndex, filteredUserSkills.length - 1)];
|
||||
if (skill) insertUserSkillToken(skill);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAllMenus();
|
||||
return;
|
||||
}
|
||||
// / command popover keyboard navigation (input-anchored picker)
|
||||
if (showSlashCommandPicker && !menuPos) {
|
||||
handleSlashCommandKeyDown(e);
|
||||
return;
|
||||
}
|
||||
}, [showAtMention, hosts, showSlashSkillPicker, filteredUserSkills, activeMenuIndex, handleSelectAtMention, insertUserSkillToken, closeAllMenus]);
|
||||
}, [showAtMention, hosts, showSlashCommandPicker, menuPos, activeMenuIndex, handleSelectAtMention, handleSlashCommandKeyDown, closeAllMenus]);
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const pastedFiles = Array.from(e.clipboardData.items)
|
||||
@@ -585,48 +708,42 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* / skill popover */}
|
||||
{showSlashSkillPicker && filteredUserSkills.length > 0 && inputPanelPos && createPortal(
|
||||
{/* / command popover */}
|
||||
{showSlashPickerUI && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Insert user skill"
|
||||
aria-activedescendant={filteredUserSkills[activeMenuIndex] ? `slash-skill-${filteredUserSkills[activeMenuIndex].id}` : undefined}
|
||||
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
|
||||
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(200, inputPanelPos.width), maxWidth: inputPanelPos.width }}
|
||||
>
|
||||
<ScrollArea className="max-h-[280px]">
|
||||
<div className="p-1">
|
||||
{filteredUserSkills.map((skill, idx) => {
|
||||
const isActive = idx === activeMenuIndex;
|
||||
return (
|
||||
<button
|
||||
id={`slash-skill-${skill.id}`}
|
||||
key={skill.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => setActiveMenuIndex(idx)}
|
||||
onClick={() => insertUserSkillToken(skill)}
|
||||
className={`w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px]">
|
||||
<Package size={12} className="text-muted-foreground/55 shrink-0" />
|
||||
<span className="text-foreground/90">/{skill.slug}</span>
|
||||
</div>
|
||||
{skill.description ? (
|
||||
<div className="pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
|
||||
{skill.description}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<SlashCommandPicker
|
||||
listRef={slashPickerListRef}
|
||||
listboxId={slashPickerListboxId}
|
||||
ariaLabel={t('ai.chat.slashCommands')}
|
||||
quickMessages={filteredQuickMessages}
|
||||
userSkills={filteredUserSkills}
|
||||
slashCommandItems={slashCommandItems}
|
||||
activeMenuIndex={activeMenuIndex}
|
||||
onActiveIndexChange={setActiveMenuIndex}
|
||||
onSelectQuickMessage={insertQuickMessage}
|
||||
onSelectSkill={insertUserSkillToken}
|
||||
quickMessagesSectionLabel={t('ai.chat.slashQuickMessages')}
|
||||
userSkillsSectionLabel={t('ai.chat.slashUserSkills')}
|
||||
noResultsLabel={slashPickerNoResultsLabel}
|
||||
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg outline-none"
|
||||
style={
|
||||
menuPos
|
||||
? {
|
||||
left: menuPos.left,
|
||||
bottom: menuPos.bottom,
|
||||
minWidth: 220,
|
||||
maxWidth: 360,
|
||||
}
|
||||
: {
|
||||
left: inputPanelPos!.left,
|
||||
bottom: inputPanelPos!.bottom,
|
||||
width: 'auto',
|
||||
minWidth: Math.min(200, inputPanelPos!.width),
|
||||
maxWidth: inputPanelPos!.width,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
@@ -696,19 +813,17 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
|
||||
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
{userSkills.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label="Insert user skill"
|
||||
onClick={() => openInputPanelMenu('slashSkill')}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<Package size={13} className="text-muted-foreground/60" />
|
||||
<span className="flex-1 text-foreground/85">{t('ai.chat.menuUserSkills')}</span>
|
||||
<ChevronRight size={10} className="text-muted-foreground/50" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label={t('ai.chat.slashCommands')}
|
||||
onClick={() => openInputPanelMenu('slashCommand')}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<MessageSquare size={13} className="text-muted-foreground/60" />
|
||||
<span className="flex-1 text-foreground/85">{t('ai.chat.menuSlashCommands')}</span>
|
||||
<ChevronRight size={10} className="text-muted-foreground/50" />
|
||||
</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
@@ -953,6 +1068,30 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
ref={quickMsgBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!showSlashCommandPicker) {
|
||||
openSlashCommandPicker('toolbar');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
iconButtonClassName,
|
||||
isSlashCatalogEmpty ? 'opacity-45 hover:opacity-80' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
aria-label={t('ai.chat.slashCommands')}
|
||||
aria-expanded={showSlashCommandPicker}
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.slashCommands')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PromptInputSubmit
|
||||
status={status}
|
||||
onStop={onStop}
|
||||
|
||||
32
components/ai/ChatMessageList.test.tsx
Normal file
32
components/ai/ChatMessageList.test.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
import ChatMessageList from "./ChatMessageList.tsx";
|
||||
|
||||
const makeMessage = (index: number): ChatMessage => ({
|
||||
id: `msg-${index}`,
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `message-${index}`,
|
||||
timestamp: index,
|
||||
});
|
||||
|
||||
test("ChatMessageList only renders the recent message batch by default", () => {
|
||||
const messages = Array.from({ length: 60 }, (_value, index) => makeMessage(index));
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(ChatMessageList, { messages }),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /Load earlier messages \(10 more\)/);
|
||||
assert.doesNotMatch(markup, /message-0/);
|
||||
assert.match(markup, /message-10/);
|
||||
assert.match(markup, /message-59/);
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { Message, MessageContent, MessageResponse } from '../ai-elements/message';
|
||||
import { ToolCall } from '../ai-elements/tool-call';
|
||||
import ThinkingBlock from './ThinkingBlock';
|
||||
import ToolCallGroup from './ToolCallGroup';
|
||||
import {
|
||||
onApprovalRequest,
|
||||
onApprovalCleared,
|
||||
@@ -208,13 +209,34 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
{t('ai.chat.loadEarlierMessages').replace('{n}', String(hiddenMessageCount))}
|
||||
</button>
|
||||
)}
|
||||
{displayedMessages.map((message) => {
|
||||
{displayedMessages.map((message, idx) => {
|
||||
if (message.role === 'tool') {
|
||||
if (hideToolCalls) return null;
|
||||
// Group consecutive tool messages into a collapsible section
|
||||
// Skip if this is NOT the first in a consecutive run
|
||||
const prevIsTool = idx > 0 && displayedMessages[idx - 1].role === "tool";
|
||||
if (prevIsTool || hideToolCalls) return null;
|
||||
|
||||
// Collect this run of consecutive tool messages
|
||||
let end = idx + 1;
|
||||
while (end < displayedMessages.length && displayedMessages[end].role === "tool") end++;
|
||||
const group = displayedMessages.slice(idx, end);
|
||||
const groupTotal = group.reduce(
|
||||
(sum, m) => sum + (m.toolResults?.length ?? 0), 0,
|
||||
);
|
||||
|
||||
// Expanded while the agent is still working (no assistant response follows)
|
||||
const hasAssistantAfter = end < displayedMessages.length
|
||||
&& displayedMessages[end].role === "assistant";
|
||||
|
||||
return (
|
||||
<React.Fragment key={message.id}>
|
||||
{message.toolResults?.map((tr) => (
|
||||
<React.Profiler key={tr.toolCallId} {...getAIPanelProfilerProps('AIChatPanel.ToolCall.Result')}>
|
||||
<ToolCallGroup
|
||||
key={`tool-group-${message.id}`}
|
||||
count={groupTotal}
|
||||
defaultExpanded={!hasAssistantAfter}
|
||||
>
|
||||
{group.map((toolMsg) =>
|
||||
toolMsg.toolResults?.map((tr) => (
|
||||
<React.Profiler key={tr.toolCallId} {...getAIPanelProfilerProps("AIChatPanel.ToolCall.Result")}>
|
||||
<div>
|
||||
<ToolCall
|
||||
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
|
||||
@@ -223,9 +245,10 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
isError={tr.isError}
|
||||
/>
|
||||
</div>
|
||||
</React.Profiler>
|
||||
))}
|
||||
</React.Fragment>
|
||||
</React.Profiler>
|
||||
)),
|
||||
)}
|
||||
</ToolCallGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -296,33 +319,39 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
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. */}
|
||||
{!hideToolCalls && (message !== lastAssistantMessage || message.executionStatus === 'cancelled') && message.toolCalls?.filter((tc) =>
|
||||
!resolvedToolCallIds.has(tc.id),
|
||||
).map((tc) => {
|
||||
const isPending = pendingApprovals.has(tc.id);
|
||||
const resolved = resolvedApprovals.get(tc.id);
|
||||
const approvalStatus = isPending
|
||||
? 'pending' as const
|
||||
: resolved === true
|
||||
? 'approved' as const
|
||||
: resolved === false
|
||||
? 'denied' as const
|
||||
: undefined;
|
||||
{(() => {
|
||||
if (hideToolCalls) return null;
|
||||
if (message === lastAssistantMessage && message.executionStatus !== "cancelled") return null;
|
||||
const unresolvedTcs = message.toolCalls?.filter((tc) => !resolvedToolCallIds.has(tc.id)) ?? [];
|
||||
if (unresolvedTcs.length === 0) return null;
|
||||
return (
|
||||
<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>
|
||||
<ToolCallGroup count={unresolvedTcs.length} defaultExpanded={false}>
|
||||
{unresolvedTcs.map((tc) => {
|
||||
const isPending = pendingApprovals.has(tc.id);
|
||||
const resolved = resolvedApprovals.get(tc.id);
|
||||
const approvalStatus = isPending
|
||||
? "pending" as const
|
||||
: resolved === true
|
||||
? "approved" as const
|
||||
: resolved === false
|
||||
? "denied" as const
|
||||
: undefined;
|
||||
return (
|
||||
<div key={tc.id} className="px-2 py-1.5">
|
||||
<ToolCall
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isInterrupted={!isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ToolCallGroup>
|
||||
);
|
||||
})}
|
||||
})()}
|
||||
|
||||
{/* Status text with shimmer */}
|
||||
{message.statusText && (
|
||||
@@ -352,33 +381,42 @@ 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. */}
|
||||
{!hideToolCalls && lastAssistantMessage?.toolCalls?.filter((tc) =>
|
||||
!resolvedToolCallIds.has(tc.id) && lastAssistantMessage.executionStatus !== 'cancelled',
|
||||
).map((tc) => {
|
||||
const isPending = pendingApprovals.has(tc.id);
|
||||
const resolved = resolvedApprovals.get(tc.id);
|
||||
const approvalStatus = isPending
|
||||
? 'pending' as const
|
||||
: resolved === true
|
||||
? 'approved' as const
|
||||
: resolved === false
|
||||
? 'denied' as const
|
||||
: undefined;
|
||||
{(() => {
|
||||
if (hideToolCalls) return null;
|
||||
const pendingTcs = lastAssistantMessage?.toolCalls?.filter((tc) =>
|
||||
!resolvedToolCallIds.has(tc.id) && lastAssistantMessage.executionStatus !== "cancelled",
|
||||
) ?? [];
|
||||
if (pendingTcs.length === 0) return null;
|
||||
const isActive = lastAssistantMessage.executionStatus !== "error";
|
||||
const isToolRunning = !!(isStreaming && lastAssistantMessage.executionStatus === "running");
|
||||
return (
|
||||
<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>
|
||||
<ToolCallGroup count={pendingTcs.length} defaultExpanded={isActive}>
|
||||
{pendingTcs.map((tc) => {
|
||||
const isPending = pendingApprovals.has(tc.id);
|
||||
const resolved = resolvedApprovals.get(tc.id);
|
||||
const approvalStatus = isPending
|
||||
? "pending" as const
|
||||
: resolved === true
|
||||
? "approved" as const
|
||||
: resolved === false
|
||||
? "denied" as const
|
||||
: undefined;
|
||||
return (
|
||||
<div key={tc.id} className="px-2 py-1.5">
|
||||
<ToolCall
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isToolRunning && !isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ToolCallGroup>
|
||||
);
|
||||
})}
|
||||
})()}
|
||||
|
||||
{/* Standalone MCP/SDK approval requests (not tied to SDK tool calls) */}
|
||||
{!hideToolCalls && Array.from(pendingApprovals.entries())
|
||||
|
||||
146
components/ai/SlashCommandPicker.tsx
Normal file
146
components/ai/SlashCommandPicker.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { MessageSquare, Package } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import type { AIQuickMessage, SlashCommandItem, UserSkillSlashOption } from '../../infrastructure/ai/quickMessages';
|
||||
import { getSlashCommandItemId } from '../../infrastructure/ai/quickMessages';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
|
||||
export interface SlashCommandPickerProps {
|
||||
listboxId: string;
|
||||
ariaLabel: string;
|
||||
quickMessages: AIQuickMessage[];
|
||||
userSkills: UserSkillSlashOption[];
|
||||
slashCommandItems: SlashCommandItem[];
|
||||
activeMenuIndex: number;
|
||||
onActiveIndexChange: (index: number) => void;
|
||||
onSelectQuickMessage: (message: AIQuickMessage) => void;
|
||||
onSelectSkill: (skill: UserSkillSlashOption) => void;
|
||||
quickMessagesSectionLabel: string;
|
||||
userSkillsSectionLabel: string;
|
||||
noResultsLabel: string;
|
||||
emptyHintLabel?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
listRef?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
|
||||
listboxId,
|
||||
ariaLabel,
|
||||
quickMessages,
|
||||
userSkills,
|
||||
slashCommandItems,
|
||||
activeMenuIndex,
|
||||
onActiveIndexChange,
|
||||
onSelectQuickMessage,
|
||||
onSelectSkill,
|
||||
quickMessagesSectionLabel,
|
||||
userSkillsSectionLabel,
|
||||
noResultsLabel,
|
||||
emptyHintLabel,
|
||||
className,
|
||||
style,
|
||||
listRef,
|
||||
}) => {
|
||||
const activeItem = slashCommandItems[activeMenuIndex];
|
||||
const activeDescendantId = activeItem ? `${listboxId}-${getSlashCommandItemId(activeItem)}` : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
aria-label={ariaLabel}
|
||||
aria-activedescendant={activeDescendantId}
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<ScrollArea className="max-h-[280px]">
|
||||
<div className="p-1">
|
||||
{slashCommandItems.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center space-y-1">
|
||||
<p className="text-[12px] text-muted-foreground/70">{noResultsLabel}</p>
|
||||
{emptyHintLabel ? (
|
||||
<p className="text-[11px] text-muted-foreground/45 leading-relaxed">{emptyHintLabel}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{quickMessages.length > 0 ? (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[10px] text-muted-foreground/40 tracking-wide">
|
||||
{quickMessagesSectionLabel}
|
||||
</div>
|
||||
{quickMessages.map((message) => {
|
||||
const idx = slashCommandItems.findIndex(
|
||||
(item) => item.kind === 'quickMessage' && item.message.id === message.id,
|
||||
);
|
||||
const isActive = idx === activeMenuIndex;
|
||||
return (
|
||||
<button
|
||||
id={`${listboxId}-${message.id}`}
|
||||
key={message.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => onActiveIndexChange(idx)}
|
||||
onClick={() => onSelectQuickMessage(message)}
|
||||
className={`w-full rounded-md px-2 py-1.5 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px] min-w-0">
|
||||
<MessageSquare size={12} className="text-muted-foreground/55 shrink-0" />
|
||||
<span className="text-foreground/90 truncate">{message.name}</span>
|
||||
<span className="text-muted-foreground/45 font-mono shrink-0">/{message.slug}</span>
|
||||
</div>
|
||||
{(message.description || message.content) ? (
|
||||
<div className="pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
|
||||
{message.description || message.content}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
{userSkills.length > 0 ? (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[10px] text-muted-foreground/40 tracking-wide">
|
||||
{userSkillsSectionLabel}
|
||||
</div>
|
||||
{userSkills.map((skill) => {
|
||||
const idx = slashCommandItems.findIndex(
|
||||
(item) => item.kind === 'skill' && item.skill.id === skill.id,
|
||||
);
|
||||
const isActive = idx === activeMenuIndex;
|
||||
return (
|
||||
<button
|
||||
id={`${listboxId}-${skill.id}`}
|
||||
key={skill.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => onActiveIndexChange(idx)}
|
||||
onClick={() => onSelectSkill(skill)}
|
||||
className={`w-full rounded-md px-2 py-1.5 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px]">
|
||||
<Package size={12} className="text-muted-foreground/55 shrink-0" />
|
||||
<span className="text-foreground/90">/{skill.slug}</span>
|
||||
</div>
|
||||
{skill.description ? (
|
||||
<div className="pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
|
||||
{skill.description}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
65
components/ai/ToolCallGroup.tsx
Normal file
65
components/ai/ToolCallGroup.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* ToolCallGroup - Collapsible container for grouped tool calls.
|
||||
*
|
||||
* Groups consecutive tool-call messages into a single collapsible section
|
||||
* (Codex-style). While the agent is still working the group stays expanded;
|
||||
* once the assistant responds it auto-collapses to "Used N tools".
|
||||
*/
|
||||
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface ToolCallGroupProps {
|
||||
count: number;
|
||||
children: React.ReactNode;
|
||||
/** When true the group starts expanded (e.g. while streaming). */
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
const ToolCallGroup: React.FC<ToolCallGroupProps> = ({
|
||||
count,
|
||||
children,
|
||||
defaultExpanded = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const prevDefault = useRef(defaultExpanded);
|
||||
|
||||
// Auto-collapse when the group transitions from "active" to "resolved"
|
||||
useEffect(() => {
|
||||
if (prevDefault.current && !defaultExpanded) {
|
||||
setExpanded(false);
|
||||
}
|
||||
prevDefault.current = defaultExpanded;
|
||||
}, [defaultExpanded]);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border/20 bg-muted/5 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer',
|
||||
'hover:bg-muted/20 transition-colors select-none',
|
||||
)}
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown size={12} className="text-muted-foreground/50 shrink-0" />
|
||||
: <ChevronRight size={12} className="text-muted-foreground/50 shrink-0" />
|
||||
}
|
||||
<span className="text-muted-foreground/70 font-medium">
|
||||
{t('ai.chat.usedTools', { n: count })}
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="border-t border-border/20 p-1.5 space-y-1.5">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolCallGroup;
|
||||
@@ -25,6 +25,14 @@ const agents: ExternalAgentConfig[] = [
|
||||
command: '/usr/local/bin/missing-backend-agent',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'unavailable-agent',
|
||||
name: 'Unavailable Agent',
|
||||
command: '/usr/local/bin/unavailable-agent',
|
||||
sdkBackend: 'cursor',
|
||||
enabled: true,
|
||||
available: false,
|
||||
},
|
||||
];
|
||||
|
||||
test('canSendWithAgent allows Catty and enabled external agents', () => {
|
||||
@@ -35,6 +43,7 @@ test('canSendWithAgent allows Catty and enabled external agents', () => {
|
||||
test('canSendWithAgent blocks missing or disabled external agents', () => {
|
||||
assert.equal(canSendWithAgent('disabled-agent', agents), false);
|
||||
assert.equal(canSendWithAgent('missing-backend-agent', agents), false);
|
||||
assert.equal(canSendWithAgent('unavailable-agent', agents), false);
|
||||
assert.equal(canSendWithAgent('missing-agent', agents), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ export function findEnabledExternalAgent(
|
||||
agents: ExternalAgentConfig[],
|
||||
agentId: string,
|
||||
): ExternalAgentConfig | undefined {
|
||||
return agents.find((agent) => agent.id === agentId && agent.enabled && Boolean(getExternalAgentSdkBackend(agent)));
|
||||
return agents.find((agent) =>
|
||||
agent.id === agentId &&
|
||||
agent.enabled &&
|
||||
agent.available !== false &&
|
||||
Boolean(getExternalAgentSdkBackend(agent)));
|
||||
}
|
||||
|
||||
export function canSendWithAgent(
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
applyDraftEntrySelection,
|
||||
applyHistorySessionSelection,
|
||||
normalizePanelView,
|
||||
panelViewsEqual,
|
||||
resolveDisplayedPanelView,
|
||||
resolveDisplayedSession,
|
||||
} from "./aiPanelViewState.ts";
|
||||
@@ -28,6 +29,23 @@ function createSession(id: string): AISession {
|
||||
};
|
||||
}
|
||||
|
||||
test("panelViewsEqual treats draft views as equal even when refs differ", () => {
|
||||
assert.equal(
|
||||
panelViewsEqual({ mode: "draft" }, { mode: "draft" }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("panelViewsEqual distinguishes session targets", () => {
|
||||
assert.equal(
|
||||
panelViewsEqual(
|
||||
{ mode: "session", sessionId: "session-1" },
|
||||
{ mode: "session", sessionId: "session-2" },
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("draft view never falls back to most recent history", () => {
|
||||
const panelView: AIPanelView = { mode: "draft" };
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
@@ -5,6 +5,22 @@ import type {
|
||||
|
||||
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: "draft" };
|
||||
|
||||
export function panelViewsEqual(
|
||||
left: AIPanelView,
|
||||
right: AIPanelView,
|
||||
): boolean {
|
||||
if (left === right) {
|
||||
return true;
|
||||
}
|
||||
if (left.mode !== right.mode) {
|
||||
return false;
|
||||
}
|
||||
if (left.mode === "session" && right.mode === "session") {
|
||||
return left.sessionId === right.sessionId;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface HistorySessionSelectionActions {
|
||||
showSessionView: (sessionId: string) => void;
|
||||
setActiveSessionId: (sessionId: string) => void;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { buildManagedAgentState } from '../settings/tabs/ai/managedAgentState';
|
||||
import {
|
||||
buildManagedAgentState,
|
||||
updateCodebuddyManagedEnv,
|
||||
} from '../settings/tabs/ai/managedAgentState';
|
||||
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
|
||||
|
||||
test('buildManagedAgentState removes stale managed agents when path detection fails', () => {
|
||||
@@ -101,6 +104,123 @@ test('buildManagedAgentState stores SDK backend keys for discovered managed agen
|
||||
assert.equal(copilotState.agents[0].acpArgs, undefined);
|
||||
});
|
||||
|
||||
test('buildManagedAgentState stores SDK backend key for discovered Cursor', () => {
|
||||
const state = buildManagedAgentState(
|
||||
[],
|
||||
'catty',
|
||||
'cursor',
|
||||
{ path: 'cursor', version: 'Cursor SDK 1.0.18', available: true },
|
||||
);
|
||||
|
||||
assert.equal(state.agents[0].id, 'discovered_cursor');
|
||||
assert.equal(state.agents[0].name, 'Cursor');
|
||||
assert.equal(state.agents[0].command, 'cursor');
|
||||
assert.equal(state.agents[0].sdkBackend, 'cursor');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState preserves a saved Cursor API key when SDK is not ready', () => {
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
id: 'discovered_cursor',
|
||||
name: 'Cursor',
|
||||
command: 'cursor',
|
||||
enabled: true,
|
||||
available: true,
|
||||
sdkBackend: 'cursor',
|
||||
apiKey: 'enc:v1:test',
|
||||
},
|
||||
];
|
||||
|
||||
const state = buildManagedAgentState(
|
||||
agents,
|
||||
'discovered_cursor',
|
||||
'cursor',
|
||||
{ path: 'cursor', version: 'Cursor SDK', available: false, installed: true },
|
||||
);
|
||||
|
||||
assert.equal(state.agents[0].id, 'discovered_cursor');
|
||||
assert.equal(state.agents[0].apiKey, 'enc:v1:test');
|
||||
assert.equal(state.agents[0].enabled, false);
|
||||
assert.equal(state.agents[0].available, false);
|
||||
assert.equal(state.defaultAgentId, 'catty');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState stores CODEBUDDY_CODE_PATH for codebuddy', () => {
|
||||
const state = buildManagedAgentState(
|
||||
[],
|
||||
'catty',
|
||||
'codebuddy',
|
||||
{ path: '/opt/homebrew/bin/codebuddy', version: '0.1.0', available: true },
|
||||
);
|
||||
|
||||
assert.equal(state.agents.length, 1);
|
||||
assert.equal(state.agents[0].command, '/opt/homebrew/bin/codebuddy');
|
||||
assert.equal(state.agents[0].sdkBackend, 'codebuddy');
|
||||
assert.deepEqual(state.agents[0].env, {
|
||||
CODEBUDDY_CODE_PATH: '/opt/homebrew/bin/codebuddy',
|
||||
});
|
||||
});
|
||||
|
||||
test('updateCodebuddyManagedEnv creates a disabled managed entry before CLI detection', () => {
|
||||
const state = updateCodebuddyManagedEnv([], 'internal', 'CODEBUDDY_API_KEY=secret');
|
||||
|
||||
assert.equal(state.length, 1);
|
||||
assert.equal(state[0].id, 'discovered_codebuddy');
|
||||
assert.equal(state[0].command, 'codebuddy');
|
||||
assert.equal(state[0].enabled, false);
|
||||
assert.deepEqual(state[0].env, {
|
||||
CODEBUDDY_INTERNET_ENVIRONMENT: 'internal',
|
||||
CODEBUDDY_API_KEY: 'secret',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildManagedAgentState preserves disabled CodeBuddy config when path detection fails', () => {
|
||||
const agents = updateCodebuddyManagedEnv([], 'ioa', 'CODEBUDDY_AUTH_TOKEN=token');
|
||||
|
||||
const state = buildManagedAgentState(
|
||||
agents,
|
||||
'discovered_codebuddy',
|
||||
'codebuddy',
|
||||
{ path: null, version: null, available: false },
|
||||
);
|
||||
|
||||
assert.equal(state.defaultAgentId, 'catty');
|
||||
assert.equal(state.agents.length, 1);
|
||||
assert.equal(state.agents[0].id, 'discovered_codebuddy');
|
||||
assert.equal(state.agents[0].enabled, false);
|
||||
assert.deepEqual(state.agents[0].env, {
|
||||
CODEBUDDY_INTERNET_ENVIRONMENT: 'ioa',
|
||||
CODEBUDDY_AUTH_TOKEN: 'token',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildManagedAgentState enables preconfigured CodeBuddy when path detection succeeds', () => {
|
||||
const agents = updateCodebuddyManagedEnv([], 'internal', 'CODEBUDDY_API_KEY=secret');
|
||||
|
||||
const state = buildManagedAgentState(
|
||||
agents,
|
||||
'catty',
|
||||
'codebuddy',
|
||||
{ path: '/opt/homebrew/bin/codebuddy', version: '0.1.0', available: true },
|
||||
);
|
||||
|
||||
assert.equal(state.agents.length, 1);
|
||||
assert.equal(state.agents[0].enabled, true);
|
||||
assert.equal(state.agents[0].command, '/opt/homebrew/bin/codebuddy');
|
||||
assert.deepEqual(state.agents[0].env, {
|
||||
CODEBUDDY_INTERNET_ENVIRONMENT: 'internal',
|
||||
CODEBUDDY_API_KEY: 'secret',
|
||||
CODEBUDDY_CODE_PATH: '/opt/homebrew/bin/codebuddy',
|
||||
});
|
||||
});
|
||||
|
||||
test('updateCodebuddyManagedEnv removes an empty pre-detection placeholder', () => {
|
||||
const agents = updateCodebuddyManagedEnv([], 'internal', 'CODEBUDDY_API_KEY=secret');
|
||||
const cleared = updateCodebuddyManagedEnv(agents, '', '');
|
||||
|
||||
assert.deepEqual(cleared, []);
|
||||
});
|
||||
|
||||
test('buildManagedAgentState does not remove user-created matching agents', () => {
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ interface IdentityCardProps {
|
||||
identity: Identity;
|
||||
viewMode: 'grid' | 'list';
|
||||
isSelected: boolean;
|
||||
reorderProps?: React.HTMLAttributes<HTMLDivElement>;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
@@ -21,6 +22,7 @@ export const IdentityCard: React.FC<IdentityCardProps> = ({
|
||||
identity,
|
||||
viewMode,
|
||||
isSelected,
|
||||
reorderProps,
|
||||
onClick,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -43,12 +45,15 @@ export const IdentityCard: React.FC<IdentityCardProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
{...reorderProps}
|
||||
className={cn(
|
||||
reorderProps && "vault-drop-indicator-row",
|
||||
"group cursor-pointer",
|
||||
viewMode === 'grid'
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
isSelected && "ring-2 ring-primary"
|
||||
isSelected && "ring-2 ring-primary",
|
||||
reorderProps?.className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
||||
@@ -27,6 +27,7 @@ interface KeyCardProps {
|
||||
viewMode: 'grid' | 'list';
|
||||
isSelected: boolean;
|
||||
isMac: boolean;
|
||||
reorderProps?: React.HTMLAttributes<HTMLDivElement>;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onExport: () => void;
|
||||
@@ -39,6 +40,7 @@ export const KeyCard: React.FC<KeyCardProps> = ({
|
||||
viewMode,
|
||||
isSelected,
|
||||
isMac,
|
||||
reorderProps,
|
||||
onClick,
|
||||
onEdit,
|
||||
onExport,
|
||||
@@ -50,12 +52,15 @@ export const KeyCard: React.FC<KeyCardProps> = ({
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
{...reorderProps}
|
||||
className={cn(
|
||||
reorderProps && "vault-drop-indicator-row",
|
||||
"group cursor-pointer",
|
||||
viewMode === 'grid'
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
isSelected && "ring-2 ring-primary"
|
||||
isSelected && "ring-2 ring-primary",
|
||||
reorderProps?.className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface RuleCardProps {
|
||||
viewMode: ViewMode;
|
||||
isSelected: boolean;
|
||||
isPending: boolean;
|
||||
reorderProps?: React.HTMLAttributes<HTMLDivElement>;
|
||||
onSelect: () => void;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
@@ -35,6 +36,7 @@ export const RuleCard: React.FC<RuleCardProps> = ({
|
||||
viewMode,
|
||||
isSelected,
|
||||
isPending,
|
||||
reorderProps,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
@@ -50,12 +52,15 @@ export const RuleCard: React.FC<RuleCardProps> = ({
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
{...reorderProps}
|
||||
className={cn(
|
||||
reorderProps && "vault-drop-indicator-row",
|
||||
"group cursor-pointer",
|
||||
viewMode === 'grid'
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
isSelected && "ring-2 ring-primary"
|
||||
isSelected && "ring-2 ring-primary",
|
||||
reorderProps?.className,
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* Sub-components live in ./ai/ directory:
|
||||
* - ProviderCard, ProviderConfigForm, AddProviderDropdown
|
||||
* - ModelSelector, ProviderIconBadge
|
||||
* - CodexConnectionCard, ClaudeCodeCard
|
||||
* - ModelSelector
|
||||
* - CodexConnectionCard, ClaudeCodeCard, CodebuddyCard
|
||||
* - SafetySettings
|
||||
*/
|
||||
import { AlertTriangle, Bot, FolderOpen, RefreshCcw } from "lucide-react";
|
||||
@@ -35,20 +35,26 @@ import {
|
||||
getBridge,
|
||||
normalizeCodexBridgeError,
|
||||
} from "./ai/types";
|
||||
import { ProviderIconBadge } from "./ai/ProviderIconBadge";
|
||||
import { ProviderCard } from "./ai/ProviderCard";
|
||||
import { AddProviderDropdown } from "./ai/AddProviderDropdown";
|
||||
import { CodexConnectionCard } from "./ai/CodexConnectionCard";
|
||||
import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
|
||||
import { CopilotCliCard } from "./ai/CopilotCliCard";
|
||||
import { CodebuddyCard } from "./ai/CodebuddyCard";
|
||||
import { SafetySettings } from "./ai/SafetySettings";
|
||||
import { WebSearchSettings } from "./ai/WebSearchSettings";
|
||||
import { QuickMessagesSettings } from "./ai/QuickMessagesSettings";
|
||||
import type { AIQuickMessage } from "../../../infrastructure/ai/quickMessages";
|
||||
import { encryptField } from "../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import { CursorSdkCard } from "./ai/CursorSdkCard";
|
||||
import {
|
||||
areExternalAgentListsEqual,
|
||||
buildManagedAgentState,
|
||||
getInitialManagedAgentPaths,
|
||||
updateCodebuddyManagedEnv,
|
||||
} from "./ai/managedAgentState";
|
||||
import { splitClaudeEnv, buildClaudeEnv } from "./ai/claudeConfigEnv";
|
||||
import { splitCodebuddyEnv } from "./ai/codebuddyConfigEnv";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
@@ -79,6 +85,8 @@ interface SettingsAITabProps {
|
||||
setMaxIterations: (value: number) => void;
|
||||
webSearchConfig: WebSearchConfig | null;
|
||||
setWebSearchConfig: (config: WebSearchConfig | null) => void;
|
||||
quickMessages: AIQuickMessage[];
|
||||
setQuickMessages: (value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -110,6 +118,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setMaxIterations,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
setQuickMessages,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
|
||||
@@ -154,6 +164,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
codex: string;
|
||||
claude: string;
|
||||
copilot: string;
|
||||
cursor: string;
|
||||
codebuddy: string;
|
||||
} | null>(null);
|
||||
if (!initialManagedPathsRef.current) {
|
||||
initialManagedPathsRef.current = getInitialManagedAgentPaths(externalAgents);
|
||||
@@ -162,8 +174,37 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [copilotCustomPath, setCopilotCustomPath] = useState("");
|
||||
const [isResolvingCopilot, setIsResolvingCopilot] = useState(false);
|
||||
|
||||
const [cursorPathInfo, setCursorPathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [isResolvingCursor, setIsResolvingCursor] = useState(false);
|
||||
|
||||
const [codebuddyPathInfo, setCodebuddyPathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [codebuddyCustomPath, setCodebuddyCustomPath] = useState("");
|
||||
const [isResolvingCodebuddy, setIsResolvingCodebuddy] = useState(false);
|
||||
|
||||
const codebuddyManagedEnv = useMemo(
|
||||
() => externalAgents.find((a) => a.id === "discovered_codebuddy")?.env,
|
||||
[externalAgents],
|
||||
);
|
||||
const {
|
||||
internetEnv: codebuddyInternetEnv,
|
||||
envText: codebuddyEnvText,
|
||||
} = useMemo(() => splitCodebuddyEnv(codebuddyManagedEnv), [codebuddyManagedEnv]);
|
||||
const updateCodebuddyEnv = useCallback(
|
||||
(nextInternetEnv: string, nextEnvText: string) => {
|
||||
setExternalAgents((prev) =>
|
||||
updateCodebuddyManagedEnv(prev, nextInternetEnv, nextEnvText),
|
||||
);
|
||||
},
|
||||
[setExternalAgents],
|
||||
);
|
||||
const [userSkillsStatus, setUserSkillsStatus] = useState<UserSkillsStatusResult | null>(null);
|
||||
const [isLoadingUserSkills, setIsLoadingUserSkills] = useState(false);
|
||||
const cursorManagedAgent = useMemo(
|
||||
() => externalAgents.find((agent) => agent.id === "discovered_cursor"),
|
||||
[externalAgents],
|
||||
);
|
||||
const cursorApiKeyEncrypted = cursorManagedAgent?.apiKey;
|
||||
|
||||
// Ref to read current defaultAgentId without adding it as a dependency.
|
||||
const defaultAgentIdRef = useRef(defaultAgentId);
|
||||
@@ -172,6 +213,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
const resolveAgentPath = useCallback(async (
|
||||
agentKey: ManagedAgentKey,
|
||||
customPath = "",
|
||||
options?: { apiKeyPresent?: boolean },
|
||||
) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiResolveCli) return null;
|
||||
@@ -180,18 +222,28 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
? setCodexPathInfo
|
||||
: agentKey === "claude"
|
||||
? setClaudePathInfo
|
||||
: setCopilotPathInfo;
|
||||
: agentKey === "copilot"
|
||||
? setCopilotPathInfo
|
||||
: agentKey === "cursor"
|
||||
? setCursorPathInfo
|
||||
: setCodebuddyPathInfo;
|
||||
const setResolving = agentKey === "codex"
|
||||
? setIsResolvingCodex
|
||||
: agentKey === "claude"
|
||||
? setIsResolvingClaude
|
||||
: setIsResolvingCopilot;
|
||||
: agentKey === "copilot"
|
||||
? setIsResolvingCopilot
|
||||
: agentKey === "cursor"
|
||||
? setIsResolvingCursor
|
||||
: setIsResolvingCodebuddy;
|
||||
|
||||
setResolving(true);
|
||||
try {
|
||||
const result = await bridge.aiResolveCli({
|
||||
command: agentKey,
|
||||
customPath: customPath.trim(),
|
||||
refreshShellEnv: agentKey === "cursor",
|
||||
...(agentKey === "cursor" ? { apiKeyPresent: Boolean(options?.apiKeyPresent ?? cursorApiKeyEncrypted) } : {}),
|
||||
});
|
||||
setInfo(result);
|
||||
|
||||
@@ -220,13 +272,15 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
} finally {
|
||||
setResolving(false);
|
||||
}
|
||||
}, [setExternalAgents, setDefaultAgentId]);
|
||||
}, [cursorApiKeyEncrypted, setExternalAgents, setDefaultAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
void resolveAgentPath("codex", initialManagedPathsRef.current?.codex ?? "");
|
||||
void resolveAgentPath("claude", initialManagedPathsRef.current?.claude ?? "");
|
||||
void resolveAgentPath("copilot", initialManagedPathsRef.current?.copilot ?? "");
|
||||
}, [resolveAgentPath]);
|
||||
void resolveAgentPath("cursor", initialManagedPathsRef.current?.cursor ?? "", { apiKeyPresent: Boolean(cursorApiKeyEncrypted) });
|
||||
void resolveAgentPath("codebuddy", initialManagedPathsRef.current?.codebuddy ?? "");
|
||||
}, [cursorApiKeyEncrypted, resolveAgentPath]);
|
||||
|
||||
// Validate a custom path for an agent
|
||||
const handleCheckCustomPath = useCallback(async (agentKey: ManagedAgentKey) => {
|
||||
@@ -234,9 +288,41 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
? codexCustomPath
|
||||
: agentKey === "claude"
|
||||
? claudeCustomPath
|
||||
: copilotCustomPath;
|
||||
: agentKey === "copilot"
|
||||
? copilotCustomPath
|
||||
: agentKey === "codebuddy"
|
||||
? codebuddyCustomPath
|
||||
: "";
|
||||
await resolveAgentPath(agentKey, customPath);
|
||||
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, resolveAgentPath]);
|
||||
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, codebuddyCustomPath, resolveAgentPath]);
|
||||
|
||||
const handleSaveCursorApiKey = useCallback(async (apiKey: string) => {
|
||||
const trimmed = apiKey.trim();
|
||||
const encrypted = trimmed ? await encryptField(trimmed) : undefined;
|
||||
const result = await resolveAgentPath("cursor", "", { apiKeyPresent: Boolean(trimmed) });
|
||||
setExternalAgents((prev) => {
|
||||
const existing = prev.find((agent) => agent.id === "discovered_cursor");
|
||||
const others = prev.filter((agent) => agent.id !== "discovered_cursor");
|
||||
if (!encrypted && !existing) return prev;
|
||||
if (!encrypted && existing && !result?.available) return others;
|
||||
const nextAgent: ExternalAgentConfig = {
|
||||
...(existing ?? {
|
||||
id: "discovered_cursor",
|
||||
name: "Cursor",
|
||||
command: result?.path || cursorPathInfo?.path || "cursor",
|
||||
args: ["{prompt}"],
|
||||
icon: "cursor",
|
||||
sdkBackend: "cursor",
|
||||
enabled: false,
|
||||
}),
|
||||
apiKey: encrypted,
|
||||
command: result?.path || existing?.command || cursorPathInfo?.path || "cursor",
|
||||
available: Boolean(result?.available),
|
||||
enabled: result?.available ? (existing?.enabled ?? true) : false,
|
||||
};
|
||||
return [...others, nextAgent];
|
||||
});
|
||||
}, [cursorPathInfo?.path, resolveAgentPath, setExternalAgents]);
|
||||
|
||||
// Add a new provider from preset
|
||||
const handleAddProvider = useCallback(
|
||||
@@ -436,6 +522,15 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
};
|
||||
}, [refreshUserSkillsStatus]);
|
||||
|
||||
const reservedUserSkillSlugs = useMemo(
|
||||
() => (userSkillsStatus?.ok && userSkillsStatus.skills
|
||||
? userSkillsStatus.skills
|
||||
.filter((skill) => skill.status === 'ready' && typeof skill.slug === 'string' && skill.slug.length > 0)
|
||||
.map((skill) => skill.slug)
|
||||
: []),
|
||||
[userSkillsStatus],
|
||||
);
|
||||
|
||||
const handleOpenUserSkillsFolder = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiUserSkillsOpenFolder) return;
|
||||
@@ -518,7 +613,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
|
||||
<SettingsSection
|
||||
title={t('ai.codex')}
|
||||
leading={<ProviderIconBadge providerId="openai" size="sm" />}
|
||||
leading={<AgentIconBadge agent={{ id: "codex", icon: "openai", name: "Codex CLI" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
|
||||
>
|
||||
<CodexConnectionCard
|
||||
pathInfo={codexPathInfo}
|
||||
@@ -540,7 +635,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
|
||||
<SettingsSection
|
||||
title={t('ai.claude.title')}
|
||||
leading={<ProviderIconBadge providerId="claude" size="sm" />}
|
||||
leading={<AgentIconBadge agent={{ id: "claude", icon: "claude", name: "Claude Code" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
|
||||
>
|
||||
<ClaudeCodeCard
|
||||
pathInfo={claudePathInfo}
|
||||
@@ -559,7 +654,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
|
||||
<SettingsSection
|
||||
title={t('ai.copilot.title')}
|
||||
leading={<ProviderIconBadge providerId="copilot" size="sm" />}
|
||||
leading={<AgentIconBadge agent={{ id: "copilot", icon: "copilot", name: "GitHub Copilot CLI" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
|
||||
>
|
||||
<CopilotCliCard
|
||||
pathInfo={copilotPathInfo}
|
||||
@@ -570,6 +665,36 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('ai.cursor.title')}
|
||||
leading={<AgentIconBadge agent={{ id: "cursor", icon: "cursor", name: "Cursor" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
|
||||
>
|
||||
<CursorSdkCard
|
||||
pathInfo={cursorPathInfo}
|
||||
isResolvingPath={isResolvingCursor}
|
||||
encryptedApiKey={cursorApiKeyEncrypted}
|
||||
onSaveApiKey={handleSaveCursorApiKey}
|
||||
onRecheckPath={() => void handleCheckCustomPath("cursor")}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('ai.codebuddy.title')}
|
||||
leading={<AgentIconBadge agent={{ id: "codebuddy", icon: "codebuddy", name: "CodeBuddy Code" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
|
||||
>
|
||||
<CodebuddyCard
|
||||
pathInfo={codebuddyPathInfo}
|
||||
isResolvingPath={isResolvingCodebuddy}
|
||||
customPath={codebuddyCustomPath}
|
||||
onCustomPathChange={setCodebuddyCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("codebuddy")}
|
||||
internetEnv={codebuddyInternetEnv}
|
||||
onInternetEnvChange={(v) => updateCodebuddyEnv(v, codebuddyEnvText)}
|
||||
envText={codebuddyEnvText}
|
||||
onEnvTextChange={(v) => updateCodebuddyEnv(codebuddyInternetEnv, v)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{agentOptions.length > 1 && (
|
||||
<SettingsSection title={t('ai.defaultAgent')}>
|
||||
<SettingCard>
|
||||
@@ -626,20 +751,20 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<SettingCard padded className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<SettingCard padded className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-muted-foreground/80 leading-5">
|
||||
{t('ai.userSkills.description')}
|
||||
</p>
|
||||
{userSkillsStatus?.directoryPath ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t('ai.userSkills.location')}:{" "}
|
||||
<span className="font-mono">{userSkillsStatus.directoryPath}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="text-xs text-muted-foreground/80">
|
||||
{isLoadingUserSkills
|
||||
? t('ai.userSkills.loading')
|
||||
: userSkillsStatus?.ok
|
||||
@@ -651,25 +776,25 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</div>
|
||||
|
||||
{userSkillsStatus?.ok && userSkillsStatus.skills && userSkillsStatus.skills.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="border-t border-border/60 divide-y divide-border/60">
|
||||
{userSkillsStatus.skills.map((skill) => (
|
||||
<div
|
||||
key={skill.id}
|
||||
className="rounded-md border border-border/60 bg-background/70 p-3"
|
||||
className="py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="font-medium">{skill.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{skill.description}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono break-all">
|
||||
<div className="text-sm font-medium">{skill.name}</div>
|
||||
<div className="text-xs text-muted-foreground leading-5">{skill.description}</div>
|
||||
<div className="text-xs text-muted-foreground/80 font-mono break-all">
|
||||
{skill.directoryName}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
skill.status === "ready"
|
||||
? "rounded-full bg-emerald-500/10 px-2 py-1 text-xs font-medium text-emerald-600"
|
||||
: "rounded-full bg-amber-500/10 px-2 py-1 text-xs font-medium text-amber-600"
|
||||
? "text-xs font-medium text-emerald-500 shrink-0"
|
||||
: "text-xs font-medium text-amber-500 shrink-0"
|
||||
}
|
||||
>
|
||||
{skill.status === "ready"
|
||||
@@ -678,7 +803,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
{skill.warnings.length > 0 ? (
|
||||
<div className="mt-3 space-y-1 text-sm text-amber-700">
|
||||
<div className="mt-2 space-y-1 text-xs text-amber-500">
|
||||
{skill.warnings.map((warning, index) => (
|
||||
<div key={`${skill.id}-${index}`} className="flex items-start gap-2">
|
||||
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
|
||||
@@ -691,13 +816,19 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
))}
|
||||
</div>
|
||||
) : userSkillsStatus?.ok ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="border-t border-border/60 pt-3 text-sm text-muted-foreground">
|
||||
{t('ai.userSkills.empty')}
|
||||
</div>
|
||||
) : null}
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<QuickMessagesSettings
|
||||
quickMessages={quickMessages}
|
||||
setQuickMessages={setQuickMessages}
|
||||
reservedUserSkillSlugs={reservedUserSkillSlugs}
|
||||
/>
|
||||
|
||||
<WebSearchSettings
|
||||
webSearchConfig={webSearchConfig}
|
||||
setWebSearchConfig={setWebSearchConfig}
|
||||
|
||||
@@ -5,11 +5,13 @@ import { keyEventToString } from "../../../domain/models";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { Button } from "../../ui/button";
|
||||
import { SectionHeader, Select, SettingsTabContent, SettingRow } from "../settings-ui";
|
||||
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
|
||||
|
||||
export default function SettingsShortcutsTab(props: {
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
setHotkeyScheme: (scheme: HotkeyScheme) => void;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
setShellOnlyTabNumberShortcuts: (enabled: boolean) => void;
|
||||
keyBindings: KeyBinding[];
|
||||
updateKeyBinding?: (bindingId: string, scheme: "mac" | "pc", newKey: string) => void;
|
||||
resetKeyBinding?: (bindingId: string, scheme?: "mac" | "pc") => void;
|
||||
@@ -19,6 +21,8 @@ export default function SettingsShortcutsTab(props: {
|
||||
const {
|
||||
hotkeyScheme,
|
||||
setHotkeyScheme,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
setShellOnlyTabNumberShortcuts,
|
||||
keyBindings,
|
||||
updateKeyBinding,
|
||||
resetKeyBinding,
|
||||
@@ -136,6 +140,15 @@ export default function SettingsShortcutsTab(props: {
|
||||
className="w-32"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.shortcuts.shellOnlyTabNumberShortcuts.label")}
|
||||
description={t("settings.shortcuts.shellOnlyTabNumberShortcuts.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={shellOnlyTabNumberShortcuts}
|
||||
onChange={setShellOnlyTabNumberShortcuts}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
{hotkeyScheme !== "disabled" && (
|
||||
|
||||
@@ -856,6 +856,94 @@ function SettingsTerminalTab(props: {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.systemManager")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.terminal.systemManager.processRefreshInterval")}
|
||||
description={t("settings.terminal.systemManager.processRefreshInterval.desc")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={2}
|
||||
max={60}
|
||||
value={terminalSettings.systemManagerProcessRefreshInterval}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10) || 3;
|
||||
if (val >= 2 && val <= 60) {
|
||||
updateTerminalSetting("systemManagerProcessRefreshInterval", val);
|
||||
}
|
||||
}}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{t("settings.terminal.serverStats.seconds")}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.systemManager.tmuxRefreshInterval")}
|
||||
description={t("settings.terminal.systemManager.tmuxRefreshInterval.desc")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={2}
|
||||
max={60}
|
||||
value={terminalSettings.systemManagerTmuxRefreshInterval}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10) || 3;
|
||||
if (val >= 2 && val <= 60) {
|
||||
updateTerminalSetting("systemManagerTmuxRefreshInterval", val);
|
||||
}
|
||||
}}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{t("settings.terminal.serverStats.seconds")}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.systemManager.dockerListRefreshInterval")}
|
||||
description={t("settings.terminal.systemManager.dockerListRefreshInterval.desc")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={3}
|
||||
max={120}
|
||||
value={terminalSettings.systemManagerDockerListRefreshInterval}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10) || 5;
|
||||
if (val >= 3 && val <= 120) {
|
||||
updateTerminalSetting("systemManagerDockerListRefreshInterval", val);
|
||||
}
|
||||
}}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{t("settings.terminal.serverStats.seconds")}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.systemManager.dockerStatsRefreshInterval")}
|
||||
description={t("settings.terminal.systemManager.dockerStatsRefreshInterval.desc")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={2}
|
||||
max={60}
|
||||
value={terminalSettings.systemManagerDockerStatsRefreshInterval}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10) || 3;
|
||||
if (val >= 2 && val <= 60) {
|
||||
updateTerminalSetting("systemManagerDockerStatsRefreshInterval", val);
|
||||
}
|
||||
}}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{t("settings.terminal.serverStats.seconds")}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.rendering")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
@@ -873,15 +961,6 @@ function SettingsTerminalTab(props: {
|
||||
className="w-32"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.rendering.lineTimestamps")}
|
||||
description={t("settings.terminal.rendering.lineTimestamps.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.showLineTimestamps}
|
||||
onChange={(v) => updateTerminalSetting("showLineTimestamps", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
{/* Autocomplete */}
|
||||
<SectionHeader title={t("settings.terminal.section.workspaceFocus")} />
|
||||
|
||||
162
components/settings/tabs/ai/CodebuddyCard.tsx
Normal file
162
components/settings/tabs/ai/CodebuddyCard.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ChevronDown, RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { AgentPathInfo } from "./types";
|
||||
import { parseEnvLines, serializeEnvLines } from "./codebuddyConfigEnv";
|
||||
|
||||
const INTERNET_ENV_OPTIONS = [
|
||||
{ value: "", labelKey: "ai.codebuddy.internetEnv.default" },
|
||||
{ value: "internal", labelKey: "ai.codebuddy.internetEnv.internal" },
|
||||
{ value: "ioa", labelKey: "ai.codebuddy.internetEnv.ioa" },
|
||||
] as const;
|
||||
|
||||
export const CodebuddyCard: React.FC<{
|
||||
pathInfo: AgentPathInfo | null;
|
||||
isResolvingPath: boolean;
|
||||
customPath: string;
|
||||
onCustomPathChange: (path: string) => void;
|
||||
onRecheckPath: () => void;
|
||||
internetEnv: string;
|
||||
onInternetEnvChange: (value: string) => void;
|
||||
envText: string;
|
||||
onEnvTextChange: (value: string) => void;
|
||||
}> = ({
|
||||
pathInfo,
|
||||
isResolvingPath,
|
||||
customPath,
|
||||
onCustomPathChange,
|
||||
onRecheckPath,
|
||||
internetEnv,
|
||||
onInternetEnvChange,
|
||||
envText,
|
||||
onEnvTextChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
// Collapsed by default; auto-expand when the user already has config so it
|
||||
// isn't hidden. Local UI state — not persisted.
|
||||
const [configOpen, setConfigOpen] = useState(
|
||||
() => Boolean(internetEnv.trim() || envText.trim()),
|
||||
);
|
||||
|
||||
// The env editor keeps the raw text the user types. Persisting parses it into
|
||||
// a record (dropping incomplete lines), so binding the textarea directly to
|
||||
// the persisted value would erase a key the moment it's typed before its "=".
|
||||
// Only resync from the persisted value when it changes for some reason other
|
||||
// than our own parse→serialize round-trip.
|
||||
const [envDraft, setEnvDraft] = useState(envText);
|
||||
useEffect(() => {
|
||||
setEnvDraft((prev) =>
|
||||
serializeEnvLines(parseEnvLines(prev)) === envText ? prev : envText,
|
||||
);
|
||||
}, [envText]);
|
||||
|
||||
const statusText = isResolvingPath
|
||||
? t('ai.codebuddy.detecting')
|
||||
: found
|
||||
? t('ai.codebuddy.detected')
|
||||
: t('ai.codebuddy.notFound');
|
||||
|
||||
const statusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
: found
|
||||
? "text-emerald-500"
|
||||
: "text-amber-500";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<p className="min-w-0 text-xs text-muted-foreground leading-5">
|
||||
{t('ai.codebuddy.description')}
|
||||
</p>
|
||||
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path detection info */}
|
||||
{found ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.codebuddy.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : !isResolvingPath ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-amber-500">
|
||||
{t('ai.codebuddy.notFoundHint')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.codebuddy.customPathPlaceholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
{t('ai.codebuddy.check')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Authentication & config (optional, collapsible) */}
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfigOpen((v) => !v)}
|
||||
aria-expanded={configOpen}
|
||||
className="flex w-full items-center justify-between gap-2 text-left"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t('ai.codebuddy.configSection')}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("text-muted-foreground transition-transform", configOpen && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
{configOpen && (
|
||||
<div className="space-y-3 mt-3">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="codebuddy-internet-env" className="text-xs text-muted-foreground">{t('ai.codebuddy.internetEnv')}</label>
|
||||
<select
|
||||
id="codebuddy-internet-env"
|
||||
value={internetEnv}
|
||||
onChange={(e) => onInternetEnvChange(e.target.value)}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm font-mono focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
{INTERNET_ENV_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{t(opt.labelKey)}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.internetEnv.hint')}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="codebuddy-env-vars" className="text-xs text-muted-foreground">{t('ai.codebuddy.envVars')}</label>
|
||||
<textarea
|
||||
id="codebuddy-env-vars"
|
||||
value={envDraft}
|
||||
onChange={(e) => { setEnvDraft(e.target.value); onEnvTextChange(e.target.value); }}
|
||||
placeholder={t('ai.codebuddy.envVars.placeholder')}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.envVars.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
40
components/settings/tabs/ai/CopilotCliCard.test.tsx
Normal file
40
components/settings/tabs/ai/CopilotCliCard.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { CopilotCliCard } from "./CopilotCliCard";
|
||||
|
||||
function firstButton(markup: string): string {
|
||||
const match = markup.match(/<button\b[^>]*>/);
|
||||
return match?.[0] ?? "";
|
||||
}
|
||||
|
||||
test("Cursor check button stays enabled without a custom path", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CopilotCliCard
|
||||
pathInfo={{ path: null, version: null, available: false }}
|
||||
isResolvingPath={false}
|
||||
customPath=""
|
||||
onCustomPathChange={() => {}}
|
||||
onRecheckPath={() => {}}
|
||||
i18nPrefix="ai.cursor"
|
||||
allowEmptyCheck
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.equal(firstButton(markup).includes("disabled=\"\""), false);
|
||||
});
|
||||
|
||||
test("Copilot check button still requires a custom path", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CopilotCliCard
|
||||
pathInfo={{ path: null, version: null, available: false }}
|
||||
isResolvingPath={false}
|
||||
customPath=""
|
||||
onCustomPathChange={() => {}}
|
||||
onRecheckPath={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.equal(firstButton(markup).includes("disabled=\"\""), true);
|
||||
});
|
||||
@@ -11,21 +11,27 @@ export const CopilotCliCard: React.FC<{
|
||||
customPath: string;
|
||||
onCustomPathChange: (path: string) => void;
|
||||
onRecheckPath: () => void;
|
||||
i18nPrefix?: "ai.copilot" | "ai.cursor";
|
||||
allowEmptyCheck?: boolean;
|
||||
showCustomPathInput?: boolean;
|
||||
}> = ({
|
||||
pathInfo,
|
||||
isResolvingPath,
|
||||
customPath,
|
||||
onCustomPathChange,
|
||||
onRecheckPath,
|
||||
i18nPrefix = "ai.copilot",
|
||||
allowEmptyCheck = false,
|
||||
showCustomPathInput = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
|
||||
const statusText = isResolvingPath
|
||||
? t('ai.copilot.detecting')
|
||||
? t(`${i18nPrefix}.detecting`)
|
||||
: found
|
||||
? t('ai.copilot.detected')
|
||||
: t('ai.copilot.notFound');
|
||||
? t(`${i18nPrefix}.detected`)
|
||||
: t(`${i18nPrefix}.notFound`);
|
||||
|
||||
const statusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
@@ -37,7 +43,7 @@ export const CopilotCliCard: React.FC<{
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<p className="min-w-0 text-xs text-muted-foreground leading-5">
|
||||
{t('ai.copilot.description')}
|
||||
{t(`${i18nPrefix}.description`)}
|
||||
</p>
|
||||
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
|
||||
{statusText}
|
||||
@@ -46,7 +52,7 @@ export const CopilotCliCard: React.FC<{
|
||||
|
||||
{found ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.copilot.path')}</span>
|
||||
<span className="text-muted-foreground">{t(`${i18nPrefix}.path`)}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
@@ -58,19 +64,21 @@ export const CopilotCliCard: React.FC<{
|
||||
) : !isResolvingPath ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-amber-500">
|
||||
{t('ai.copilot.notFoundHint')}
|
||||
{t(`${i18nPrefix}.notFoundHint`)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.copilot.customPathPlaceholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<div className={cn("flex items-center gap-2", showCustomPathInput ? "" : "justify-end")}>
|
||||
{showCustomPathInput && (
|
||||
<input
|
||||
type="text"
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t(`${i18nPrefix}.customPathPlaceholder`)}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!allowEmptyCheck && !customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
{t('ai.copilot.check')}
|
||||
{t(`${i18nPrefix}.check`)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
159
components/settings/tabs/ai/CursorSdkCard.tsx
Normal file
159
components/settings/tabs/ai/CursorSdkCard.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Check, Eye, EyeOff, RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { AgentPathInfo } from "./types";
|
||||
|
||||
export const CursorSdkCard: React.FC<{
|
||||
pathInfo: AgentPathInfo | null;
|
||||
isResolvingPath: boolean;
|
||||
encryptedApiKey?: string;
|
||||
onSaveApiKey: (apiKey: string) => Promise<void>;
|
||||
onRecheckPath: () => void;
|
||||
}> = ({
|
||||
pathInfo,
|
||||
isResolvingPath,
|
||||
encryptedApiKey,
|
||||
onSaveApiKey,
|
||||
onRecheckPath,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [apiKeyDraft, setApiKeyDraft] = useState("");
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSaved(false);
|
||||
if (!encryptedApiKey) {
|
||||
setApiKeyDraft("");
|
||||
return;
|
||||
}
|
||||
setIsDecrypting(true);
|
||||
decryptField(encryptedApiKey)
|
||||
.then((value) => {
|
||||
if (!cancelled) setApiKeyDraft(value ?? "");
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setApiKeyDraft("");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsDecrypting(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [encryptedApiKey]);
|
||||
|
||||
const installed = Boolean(pathInfo?.installed);
|
||||
const available = Boolean(pathInfo?.available);
|
||||
const hasStoredApiKey = Boolean(encryptedApiKey);
|
||||
const usesEnvApiKey = pathInfo?.authSource === "CURSOR_API_KEY";
|
||||
const hasAnyApiKey = hasStoredApiKey || usesEnvApiKey;
|
||||
const canSave = !isSaving && !isDecrypting && (Boolean(apiKeyDraft.trim()) || hasStoredApiKey);
|
||||
|
||||
const installStatus = isResolvingPath
|
||||
? t("ai.cursor.detecting")
|
||||
: installed
|
||||
? t("ai.cursor.installed")
|
||||
: t("ai.cursor.notInstalled");
|
||||
const keyStatus = hasAnyApiKey
|
||||
? usesEnvApiKey && !hasStoredApiKey
|
||||
? t("ai.cursor.apiKeyFromEnv")
|
||||
: t("ai.cursor.apiKeyConfigured")
|
||||
: t("ai.cursor.apiKeyMissing");
|
||||
|
||||
const installStatusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
: installed
|
||||
? "text-emerald-500"
|
||||
: "text-amber-500";
|
||||
const keyStatusClassName = hasAnyApiKey ? "text-emerald-500" : "text-amber-500";
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
setSaved(false);
|
||||
try {
|
||||
await onSaveApiKey(apiKeyDraft.trim());
|
||||
setSaved(true);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="grid gap-2 text-xs">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{t("ai.cursor.installStatus")}</span>
|
||||
<span className={cn("font-medium", installStatusClassName)}>{installStatus}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{t("ai.cursor.apiKeyStatus")}</span>
|
||||
<span className={cn("font-medium", keyStatusClassName)}>{keyStatus}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!available && (
|
||||
<p className="text-xs text-amber-500">
|
||||
{installed ? t("ai.cursor.notFoundHint") : t("ai.cursor.notInstalledHint")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t("ai.cursor.apiKey")}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={isDecrypting ? "" : apiKeyDraft}
|
||||
onChange={(event) => {
|
||||
setSaved(false);
|
||||
setApiKeyDraft(event.target.value);
|
||||
}}
|
||||
placeholder={
|
||||
isDecrypting
|
||||
? t("ai.providers.apiKey.decrypting")
|
||||
: usesEnvApiKey && !hasStoredApiKey
|
||||
? t("ai.cursor.apiKeyPlaceholder.env")
|
||||
: t("ai.cursor.apiKeyPlaceholder")
|
||||
}
|
||||
disabled={isDecrypting}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 pr-9 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey((value) => !value)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showApiKey ? t("ai.cursor.hideApiKey") : t("ai.cursor.showApiKey")}
|
||||
>
|
||||
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={!canSave}>
|
||||
{saved ? <Check size={14} className="mr-1.5" /> : null}
|
||||
{saved ? t("ai.cursor.saved") : t("ai.cursor.saveApiKey")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={isResolvingPath}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
{t("ai.cursor.check")}
|
||||
</Button>
|
||||
</div>
|
||||
{usesEnvApiKey && !hasStoredApiKey ? (
|
||||
<p className="text-[11px] text-muted-foreground leading-4">
|
||||
{t("ai.cursor.apiKeyEnvHint")}
|
||||
</p>
|
||||
) : null}
|
||||
{usesEnvApiKey && hasStoredApiKey ? (
|
||||
<p className="text-[11px] text-muted-foreground leading-4">
|
||||
{t("ai.cursor.apiKeyOverrideHint")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
304
components/settings/tabs/ai/QuickMessagesSettings.tsx
Normal file
304
components/settings/tabs/ai/QuickMessagesSettings.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { MessageSquare, Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import type { AIQuickMessage } from "../../../../infrastructure/ai/quickMessages";
|
||||
import {
|
||||
createQuickMessageId,
|
||||
isValidQuickMessageSlug,
|
||||
normalizeQuickMessageSlug,
|
||||
QUICK_MESSAGE_LIMITS,
|
||||
slugFromQuickMessageName,
|
||||
} from "../../../../infrastructure/ai/quickMessages";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { SettingCard, SettingsSection } from "../../settings-ui";
|
||||
|
||||
interface QuickMessagesSettingsProps {
|
||||
quickMessages: AIQuickMessage[];
|
||||
setQuickMessages: (value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => void;
|
||||
reservedUserSkillSlugs?: string[];
|
||||
}
|
||||
|
||||
type DraftQuickMessage = {
|
||||
name: string;
|
||||
slug: string;
|
||||
content: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const emptyDraft = (): DraftQuickMessage => ({
|
||||
name: "",
|
||||
slug: "",
|
||||
content: "",
|
||||
description: "",
|
||||
});
|
||||
|
||||
export const QuickMessagesSettings: React.FC<QuickMessagesSettingsProps> = ({
|
||||
quickMessages,
|
||||
setQuickMessages,
|
||||
reservedUserSkillSlugs = [],
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [draft, setDraft] = useState<DraftQuickMessage>(emptyDraft);
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const sortedMessages = useMemo(
|
||||
() => [...quickMessages].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[quickMessages],
|
||||
);
|
||||
|
||||
const resetEditor = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setIsCreating(false);
|
||||
setDraft(emptyDraft());
|
||||
setSlugTouched(false);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const beginCreate = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setIsCreating(true);
|
||||
setDraft(emptyDraft());
|
||||
setSlugTouched(false);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const beginEdit = useCallback((message: AIQuickMessage) => {
|
||||
setIsCreating(false);
|
||||
setEditingId(message.id);
|
||||
setDraft({
|
||||
name: message.name,
|
||||
slug: message.slug,
|
||||
content: message.content,
|
||||
description: message.description ?? "",
|
||||
});
|
||||
setSlugTouched(true);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleNameChange = useCallback((name: string) => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
name,
|
||||
slug: slugTouched ? prev.slug : slugFromQuickMessageName(name),
|
||||
}));
|
||||
}, [slugTouched]);
|
||||
|
||||
const handleSlugChange = useCallback((slug: string) => {
|
||||
setSlugTouched(true);
|
||||
setDraft((prev) => ({ ...prev, slug: normalizeQuickMessageSlug(slug) }));
|
||||
}, []);
|
||||
|
||||
const validateDraft = useCallback((nextDraft: DraftQuickMessage, excludeId?: string | null): string | null => {
|
||||
const name = nextDraft.name.trim();
|
||||
const slug = normalizeQuickMessageSlug(nextDraft.slug);
|
||||
const content = nextDraft.content.trim();
|
||||
|
||||
if (!name) return t("ai.quickMessages.error.nameRequired");
|
||||
if (!isValidQuickMessageSlug(slug)) return t("ai.quickMessages.error.invalidSlug");
|
||||
if (!content) return t("ai.quickMessages.error.contentRequired");
|
||||
|
||||
if (!excludeId && quickMessages.length >= QUICK_MESSAGE_LIMITS.maxItems) {
|
||||
return t("ai.quickMessages.error.maxItems", { max: String(QUICK_MESSAGE_LIMITS.maxItems) });
|
||||
}
|
||||
|
||||
const slugTaken = quickMessages.some(
|
||||
(message) => message.slug === slug && message.id !== excludeId,
|
||||
);
|
||||
if (slugTaken) return t("ai.quickMessages.error.slugTaken");
|
||||
|
||||
const skillConflict = reservedUserSkillSlugs.some((skillSlug) => skillSlug === slug);
|
||||
if (skillConflict) {
|
||||
return t("ai.quickMessages.error.slugConflictsWithSkill", { slug });
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [quickMessages, reservedUserSkillSlugs, t]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const validationError = validateDraft(draft, editingId);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: AIQuickMessage = {
|
||||
id: editingId ?? createQuickMessageId(),
|
||||
name: draft.name.trim(),
|
||||
slug: normalizeQuickMessageSlug(draft.slug),
|
||||
content: draft.content.trim(),
|
||||
description: draft.description.trim() || undefined,
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
setQuickMessages((prev) => prev.map((message) => (
|
||||
message.id === editingId ? payload : message
|
||||
)));
|
||||
} else {
|
||||
setQuickMessages((prev) => [...prev, payload]);
|
||||
}
|
||||
resetEditor();
|
||||
}, [draft, editingId, resetEditor, setQuickMessages, validateDraft]);
|
||||
|
||||
const handleDelete = useCallback((message: AIQuickMessage) => {
|
||||
const ok = window.confirm(t("ai.quickMessages.confirmDelete", { name: message.name }));
|
||||
if (!ok) return;
|
||||
setQuickMessages((prev) => prev.filter((item) => item.id !== message.id));
|
||||
if (editingId === message.id) {
|
||||
resetEditor();
|
||||
}
|
||||
}, [editingId, resetEditor, setQuickMessages, t]);
|
||||
|
||||
const showEditor = isCreating || editingId != null;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t("ai.quickMessages.title")}
|
||||
actions={(
|
||||
<Button variant="outline" size="sm" onClick={beginCreate} disabled={showEditor}>
|
||||
<Plus size={14} className="mr-2" />
|
||||
{t("ai.quickMessages.add")}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<SettingCard padded className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground/80 leading-5">
|
||||
{t("ai.quickMessages.description")}
|
||||
</p>
|
||||
|
||||
{showEditor ? (
|
||||
<div className="rounded-md border border-border/60 bg-background/40 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium">
|
||||
{isCreating ? t("ai.quickMessages.createTitle") : t("ai.quickMessages.editTitle")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetEditor}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted/30 hover:text-foreground transition-colors"
|
||||
aria-label={t("common.cancel")}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-muted-foreground">{t("ai.quickMessages.name")}</span>
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder={t("ai.quickMessages.name.placeholder")}
|
||||
maxLength={QUICK_MESSAGE_LIMITS.name}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-muted-foreground">{t("ai.quickMessages.slug")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground/70">/</span>
|
||||
<input
|
||||
value={draft.slug}
|
||||
onChange={(e) => handleSlugChange(e.target.value)}
|
||||
placeholder={t("ai.quickMessages.slug.placeholder")}
|
||||
maxLength={QUICK_MESSAGE_LIMITS.slug}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-1.5 text-sm">
|
||||
<span className="text-muted-foreground">{t("ai.quickMessages.descriptionField")}</span>
|
||||
<input
|
||||
value={draft.description}
|
||||
onChange={(e) => setDraft((prev) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder={t("ai.quickMessages.descriptionField.placeholder")}
|
||||
maxLength={QUICK_MESSAGE_LIMITS.description}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1.5 text-sm">
|
||||
<span className="text-muted-foreground">{t("ai.quickMessages.content")}</span>
|
||||
<textarea
|
||||
value={draft.content}
|
||||
onChange={(e) => setDraft((prev) => ({ ...prev, content: e.target.value }))}
|
||||
placeholder={t("ai.quickMessages.content.placeholder")}
|
||||
rows={5}
|
||||
maxLength={QUICK_MESSAGE_LIMITS.content}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-y min-h-[120px]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={resetEditor}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sortedMessages.length > 0 ? (
|
||||
<div className="border-t border-border/60 divide-y divide-border/60">
|
||||
{sortedMessages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className="py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare size={14} className="text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium">{message.name}</span>
|
||||
<span className="text-xs font-mono text-muted-foreground/80">/{message.slug}</span>
|
||||
</div>
|
||||
{message.description ? (
|
||||
<p className="text-xs text-muted-foreground leading-5">{message.description}</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground/70 line-clamp-2 whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => beginEdit(message)}
|
||||
aria-label={t("ai.quickMessages.editTitle")}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDelete(message)}
|
||||
aria-label={t("ai.quickMessages.confirmDelete", { name: message.name })}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !showEditor ? (
|
||||
<div className="border-t border-border/60 pt-3 text-sm text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground">{t("ai.quickMessages.empty")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user