Compare commits
6 Commits
v1.1.39
...
fix/scroll
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
850d038c5a | ||
|
|
52bc48f73a | ||
|
|
46755465f9 | ||
|
|
ecadc1fc2d | ||
|
|
79ccf47655 | ||
|
|
6ef0a4ad6b |
2
App.tsx
2
App.tsx
@@ -990,7 +990,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
logViews={logViews}
|
||||
t={t}
|
||||
/>
|
||||
<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, removeSessionFromWorkspace, 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, renameSessionInline, 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 }} />
|
||||
<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, removeSessionFromWorkspace, 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, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, 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,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { executeHotkeyActionImpl, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
|
||||
import { executeHotkeyActionImpl, getLogHostVisualSnapshot, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
|
||||
import { matchesKeyBinding } from '../domain/models.ts';
|
||||
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
|
||||
|
||||
@@ -169,3 +169,27 @@ test('quick switch hotkey toggles the quick switcher open state', () => {
|
||||
executeHotkeyActionImpl(() => ({ ...baseCtx, isQuickSwitcherOpen: true }), 'quickSwitch', event);
|
||||
assert.equal(isQuickSwitcherOpen, false);
|
||||
});
|
||||
|
||||
test('connection log host snapshot includes custom host icon fields', () => {
|
||||
assert.deepEqual(
|
||||
getLogHostVisualSnapshot({
|
||||
id: 'host-1',
|
||||
label: 'Database',
|
||||
hostname: 'db.example.com',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
distro: 'ubuntu',
|
||||
iconMode: 'custom',
|
||||
iconId: 'database',
|
||||
iconColor: 'blue',
|
||||
}),
|
||||
{
|
||||
hostOs: 'linux',
|
||||
hostDistro: 'ubuntu',
|
||||
hostIconMode: 'custom',
|
||||
hostIconId: 'database',
|
||||
hostIconColor: 'blue',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,16 +3,23 @@ import type React from 'react';
|
||||
import type { Host, HostProtocol } from '../../types';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import { getEffectiveHostDistro } from '../../domain/host';
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
|
||||
type AppContextGetter = () => Record<string, any>;
|
||||
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
|
||||
|
||||
const getLogHostVisualSnapshot = (host: Host) => ({
|
||||
hostOs: host.os,
|
||||
hostDistro: getEffectiveHostDistro(host) || undefined,
|
||||
});
|
||||
export const getLogHostVisualSnapshot = (host: Host) => {
|
||||
const icon = sanitizeHostIconFields(host);
|
||||
return {
|
||||
hostOs: host.os,
|
||||
hostDistro: getEffectiveHostDistro(host) || undefined,
|
||||
hostIconMode: icon.iconMode,
|
||||
hostIconId: icon.iconId,
|
||||
hostIconColor: icon.iconColor,
|
||||
};
|
||||
};
|
||||
|
||||
export function handleTrayJumpToSessionImpl(getCtx: AppContextGetter, sessionId: string) {
|
||||
const { sessions, setActiveTabId, setWorkspaceFocusedSession } = getCtx();
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { GroupConfig, Host, TerminalSession, TerminalTheme, Workspace } fro
|
||||
import {
|
||||
isHostTreeWorkTabSurface,
|
||||
resolveWorkTabActiveHostId,
|
||||
resolveWorkTabHostTreeTheme,
|
||||
} from './workTabSurface';
|
||||
|
||||
interface AppHostTreeLayerProps {
|
||||
@@ -20,7 +21,12 @@ interface AppHostTreeLayerProps {
|
||||
editorTabs: readonly EditorTab[];
|
||||
logViews: readonly LogView[];
|
||||
orderedTabs: readonly string[];
|
||||
resolvedPreviewTheme: TerminalTheme;
|
||||
accentMode: 'theme' | 'custom';
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
onConnect: (host: Host) => void;
|
||||
onCreateLocalTerminal?: () => void;
|
||||
}
|
||||
@@ -43,7 +49,12 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
editorTabs,
|
||||
logViews,
|
||||
orderedTabs,
|
||||
resolvedPreviewTheme,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
onConnect,
|
||||
onCreateLocalTerminal,
|
||||
}) => {
|
||||
@@ -67,6 +78,24 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
workspaces,
|
||||
}), [activeTabId, editorTabs, sessions, workspaces]);
|
||||
|
||||
const hostTreeTheme = useMemo(() => resolveWorkTabHostTreeTheme({
|
||||
activeHostId,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
}), [
|
||||
activeHostId,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 flex min-h-0"
|
||||
@@ -79,7 +108,7 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
resolvedPreviewTheme={resolvedPreviewTheme}
|
||||
resolvedPreviewTheme={hostTreeTheme}
|
||||
activeHostId={activeHostId}
|
||||
onConnect={onConnect}
|
||||
onCreateLocalTerminal={onCreateLocalTerminal}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
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,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
||||
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
|
||||
updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
|
||||
updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces,
|
||||
@@ -153,7 +153,12 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
editorTabs={editorTabs}
|
||||
logViews={logViews}
|
||||
orderedTabs={orderedTabsWithEditors}
|
||||
resolvedPreviewTheme={currentTerminalTheme}
|
||||
accentMode={accentMode}
|
||||
currentTerminalTheme={currentTerminalTheme}
|
||||
customAccent={customAccent}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
hostById={hostById}
|
||||
themeById={themeById}
|
||||
onConnect={handleConnectToHost}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
/>
|
||||
|
||||
@@ -39,7 +39,7 @@ const baseInput = {
|
||||
workspaceById: new Map<string, Workspace>(),
|
||||
};
|
||||
|
||||
test("editor tabs use the theme from their owning host", () => {
|
||||
test("editor tabs use the owning host terminal theme when follow-app terminal theme is off", () => {
|
||||
const editorTab = {
|
||||
id: "editor-1",
|
||||
hostId: "host-1",
|
||||
@@ -58,6 +58,26 @@ test("editor tabs use the theme from their owning host", () => {
|
||||
assert.equal(resolved?.id, hostTheme.id);
|
||||
});
|
||||
|
||||
test("editor tabs use the followed terminal theme when follow-app terminal theme is on", () => {
|
||||
const editorTab = {
|
||||
id: "editor-1",
|
||||
hostId: "host-1",
|
||||
sessionId: "sftp-1",
|
||||
};
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: toEditorTabId(editorTab.id),
|
||||
editorTabs: [editorTab as unknown as EditorTab],
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([
|
||||
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
|
||||
]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test("log tabs use the saved log theme when available", () => {
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
|
||||
@@ -54,22 +54,21 @@ export function resolveActiveChromeTheme({
|
||||
}: ResolveActiveChromeThemeInput): TerminalTheme | null {
|
||||
if (activeTabId === "vault" || activeTabId === "sftp") return null;
|
||||
|
||||
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => {
|
||||
const resolveHostTheme = (hostId: string): TerminalTheme => {
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
const host = hostById.get(session.hostId) ?? null;
|
||||
const host = hostById.get(hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
};
|
||||
|
||||
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => resolveHostTheme(session.hostId);
|
||||
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
const editorTabId = fromEditorTabId(activeTabId);
|
||||
const editorTab = editorTabs.find((tab) => tab.id === editorTabId);
|
||||
if (!editorTab) return null;
|
||||
const host = hostById.get(editorTab.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
return resolveHostTheme(editorTab.hostId);
|
||||
}
|
||||
|
||||
const logView = logViews.find((item) => item.id === activeTabId);
|
||||
|
||||
@@ -8,9 +8,38 @@ import {
|
||||
isTerminalContentTabSurface,
|
||||
reorderWorkTabIds,
|
||||
resolveWorkTabActiveHostId,
|
||||
resolveWorkTabHostTreeTheme,
|
||||
} from './workTabSurface';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { TerminalSession, Workspace } from '../../types';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
|
||||
const makeTheme = (id: string, type: TerminalTheme['type'], background: string): TerminalTheme => ({
|
||||
id,
|
||||
name: id,
|
||||
type,
|
||||
colors: {
|
||||
background,
|
||||
foreground: type === 'dark' ? '#ffffff' : '#000000',
|
||||
cursor: '#888888',
|
||||
selection: '#555555',
|
||||
black: '#000000',
|
||||
red: '#ff0000',
|
||||
green: '#00ff00',
|
||||
yellow: '#ffff00',
|
||||
blue: '#0000ff',
|
||||
magenta: '#ff00ff',
|
||||
cyan: '#00ffff',
|
||||
white: '#ffffff',
|
||||
brightBlack: '#444444',
|
||||
brightRed: '#ff5555',
|
||||
brightGreen: '#55ff55',
|
||||
brightYellow: '#ffff55',
|
||||
brightBlue: '#5555ff',
|
||||
brightMagenta: '#ff55ff',
|
||||
brightCyan: '#55ffff',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
});
|
||||
|
||||
test('work tab order keeps custom positions and appends new tabs', () => {
|
||||
assert.deepEqual(
|
||||
@@ -104,3 +133,73 @@ test('shared host tree resolves active host ids across work tab types', () => {
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'editor:file-1', sessions, workspaces, editorTabs }), 'host-3');
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'log-1', sessions, workspaces, editorTabs }), null);
|
||||
});
|
||||
|
||||
test('shared host tree uses the active host theme when follow-app terminal theme is off', () => {
|
||||
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
|
||||
const hostTheme = makeTheme('host-light', 'light', '#fafafa');
|
||||
const host = {
|
||||
id: 'host-1',
|
||||
label: 'Host',
|
||||
hostname: 'host.local',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
theme: hostTheme.id,
|
||||
themeOverride: true,
|
||||
} as Host;
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: host.id,
|
||||
accentMode: 'theme',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '#8b5cf6',
|
||||
followAppTerminalTheme: false,
|
||||
hostById: new Map([[host.id, host]]),
|
||||
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, hostTheme.id);
|
||||
});
|
||||
|
||||
test('shared host tree uses the followed terminal theme when follow-app terminal theme is on', () => {
|
||||
const currentTheme = makeTheme('app-light', 'light', '#ffffff');
|
||||
const hostTheme = makeTheme('host-dark', 'dark', '#050505');
|
||||
const host = {
|
||||
id: 'host-1',
|
||||
label: 'Host',
|
||||
hostname: 'host.local',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
theme: hostTheme.id,
|
||||
themeOverride: true,
|
||||
} as Host;
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: host.id,
|
||||
accentMode: 'theme',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '#8b5cf6',
|
||||
followAppTerminalTheme: true,
|
||||
hostById: new Map([[host.id, host]]),
|
||||
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
});
|
||||
|
||||
test('shared host tree falls back to the current terminal theme without an active host', () => {
|
||||
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
|
||||
|
||||
const resolved = resolveWorkTabHostTreeTheme({
|
||||
activeHostId: null,
|
||||
accentMode: 'theme',
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: '#8b5cf6',
|
||||
followAppTerminalTheme: false,
|
||||
hostById: new Map(),
|
||||
themeById: new Map([[currentTheme.id, currentTheme]]),
|
||||
});
|
||||
|
||||
assert.equal(resolved.id, currentTheme.id);
|
||||
});
|
||||
|
||||
@@ -2,8 +2,9 @@ import {
|
||||
fromEditorTabId,
|
||||
isEditorTabId,
|
||||
} from '../state/activeTabStore';
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from '../../domain/terminalAppearance';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { TerminalSession, Workspace } from '../../types';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
|
||||
function uniqueTabIds(tabIds: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
@@ -125,3 +126,28 @@ export function resolveWorkTabActiveHostId({
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveWorkTabHostTreeTheme({
|
||||
activeHostId,
|
||||
accentMode,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
themeById,
|
||||
}: {
|
||||
activeHostId: string | null;
|
||||
accentMode: 'theme' | 'custom';
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: ReadonlyMap<string, Host>;
|
||||
themeById: ReadonlyMap<string, TerminalTheme>;
|
||||
}): TerminalTheme {
|
||||
if (!activeHostId || followAppTerminalTheme) return currentTerminalTheme;
|
||||
|
||||
const host = hostById.get(activeHostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}
|
||||
|
||||
@@ -312,6 +312,15 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.font.size.desc': 'Terminal text size',
|
||||
'settings.terminal.font.weight': 'Font weight',
|
||||
'settings.terminal.font.weight.desc': 'Weight for regular text (100-900)',
|
||||
'settings.terminal.font.weight.thin': 'Thin',
|
||||
'settings.terminal.font.weight.extraLight': 'Extra Light',
|
||||
'settings.terminal.font.weight.light': 'Light',
|
||||
'settings.terminal.font.weight.normal': 'Normal',
|
||||
'settings.terminal.font.weight.medium': 'Medium',
|
||||
'settings.terminal.font.weight.semiBold': 'Semi Bold',
|
||||
'settings.terminal.font.weight.bold': 'Bold',
|
||||
'settings.terminal.font.weight.extraBold': 'Extra Bold',
|
||||
'settings.terminal.font.weight.black': 'Black',
|
||||
'settings.terminal.font.weightBold': 'Bold font weight',
|
||||
'settings.terminal.font.weightBold.desc': 'Weight for bold text (100-900)',
|
||||
'settings.terminal.font.linePadding': 'Line padding',
|
||||
|
||||
@@ -151,6 +151,9 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
|
||||
'sftp.context.download': 'Download',
|
||||
'sftp.context.copyToOtherPane': 'Copy to other pane',
|
||||
'sftp.copyCurrentPath': 'Copy current path',
|
||||
'sftp.copyCurrentPath.success': 'Current path copied',
|
||||
'sftp.copyCurrentPath.error': 'Could not copy current path',
|
||||
'sftp.viewMode.label': 'View mode',
|
||||
'sftp.viewMode.list': 'List view',
|
||||
'sftp.viewMode.tree': 'Tree view',
|
||||
@@ -464,7 +467,52 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.section.portCredentials': 'Port & Credentials',
|
||||
'hostDetails.section.appearance': 'Appearance',
|
||||
'hostDetails.distro.title': 'Linux Distribution',
|
||||
'hostDetails.distro.desc': 'Auto-detect on connect, or override the distro icon manually.',
|
||||
'hostDetails.distro.desc': 'Controls the automatic host icon. A custom Host Icon overrides this display.',
|
||||
'hostDetails.icon.title': 'Host Icon',
|
||||
'hostDetails.icon.desc': 'Use automatic distro icons with optional color, or choose a built-in icon.',
|
||||
'hostDetails.icon.mode.auto': 'Automatic',
|
||||
'hostDetails.icon.mode.custom': 'Custom',
|
||||
'hostDetails.icon.reset': 'Reset host icon',
|
||||
'hostDetails.icon.showLibrary': 'Show icon library',
|
||||
'hostDetails.icon.hideLibrary': 'Hide icon library',
|
||||
'hostDetails.icon.autoUsesDistro': 'Use Linux Distribution icon and selected color for this host.',
|
||||
'hostDetails.icon.customOverridesDistro': 'Built-in icon replaces Linux Distribution for this host.',
|
||||
'hostDetails.icon.option.server': 'Server',
|
||||
'hostDetails.icon.option.terminal': 'Terminal',
|
||||
'hostDetails.icon.option.database': 'Database',
|
||||
'hostDetails.icon.option.cloud': 'Cloud',
|
||||
'hostDetails.icon.option.router': 'Router',
|
||||
'hostDetails.icon.option.shield': 'Shield',
|
||||
'hostDetails.icon.option.code': 'Code',
|
||||
'hostDetails.icon.option.box': 'Box',
|
||||
'hostDetails.icon.option.globe': 'Globe',
|
||||
'hostDetails.icon.option.cpu': 'CPU',
|
||||
'hostDetails.icon.option.hard-drive': 'Storage',
|
||||
'hostDetails.icon.option.network': 'Network',
|
||||
'hostDetails.icon.option.wifi': 'Wireless',
|
||||
'hostDetails.icon.option.lock': 'Lock',
|
||||
'hostDetails.icon.option.key': 'Key',
|
||||
'hostDetails.icon.option.monitor': 'Monitor',
|
||||
'hostDetails.icon.option.container': 'Container',
|
||||
'hostDetails.icon.option.activity': 'Activity',
|
||||
'hostDetails.icon.option.zap': 'Fast',
|
||||
'hostDetails.icon.option.server-cog': 'Server settings',
|
||||
'hostDetails.icon.color.blue': 'Blue',
|
||||
'hostDetails.icon.color.green': 'Green',
|
||||
'hostDetails.icon.color.red': 'Red',
|
||||
'hostDetails.icon.color.amber': 'Amber',
|
||||
'hostDetails.icon.color.purple': 'Purple',
|
||||
'hostDetails.icon.color.cyan': 'Cyan',
|
||||
'hostDetails.icon.color.orange': 'Orange',
|
||||
'hostDetails.icon.color.slate': 'Slate',
|
||||
'hostDetails.icon.color.violet': 'Violet',
|
||||
'hostDetails.icon.color.pink': 'Pink',
|
||||
'hostDetails.icon.color.rose': 'Rose',
|
||||
'hostDetails.icon.color.lime': 'Lime',
|
||||
'hostDetails.icon.color.teal': 'Teal',
|
||||
'hostDetails.icon.color.sky': 'Sky',
|
||||
'hostDetails.icon.color.indigo': 'Indigo',
|
||||
'hostDetails.icon.color.zinc': 'Zinc',
|
||||
'hostDetails.distro.mode': 'Source',
|
||||
'hostDetails.distro.mode.auto': 'Auto-detect',
|
||||
'hostDetails.distro.mode.manual': 'Manual override',
|
||||
|
||||
@@ -312,6 +312,15 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.font.size.desc': 'Размер текста терминала',
|
||||
'settings.terminal.font.weight': 'Толщина шрифта',
|
||||
'settings.terminal.font.weight.desc': 'Толщина обычного текста (100-900)',
|
||||
'settings.terminal.font.weight.thin': 'Тонкий',
|
||||
'settings.terminal.font.weight.extraLight': 'Очень светлый',
|
||||
'settings.terminal.font.weight.light': 'Светлый',
|
||||
'settings.terminal.font.weight.normal': 'Обычный',
|
||||
'settings.terminal.font.weight.medium': 'Средний',
|
||||
'settings.terminal.font.weight.semiBold': 'Полужирный',
|
||||
'settings.terminal.font.weight.bold': 'Жирный',
|
||||
'settings.terminal.font.weight.extraBold': 'Очень жирный',
|
||||
'settings.terminal.font.weight.black': 'Максимально жирный',
|
||||
'settings.terminal.font.weightBold': 'Толщина жирного шрифта',
|
||||
'settings.terminal.font.weightBold.desc': 'Толщина жирного текста (100-900)',
|
||||
'settings.terminal.font.linePadding': 'Межстрочный отступ',
|
||||
@@ -500,6 +509,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.binding.next-tab': 'Следующая вкладка',
|
||||
'settings.shortcuts.binding.prev-tab': 'Предыдущая вкладка',
|
||||
'settings.shortcuts.binding.close-tab': 'Закрыть вкладку',
|
||||
'settings.shortcuts.binding.close-session': 'Закрыть панель сессии',
|
||||
'settings.shortcuts.binding.new-tab': 'Новая локальная вкладка',
|
||||
'settings.shortcuts.binding.copy': 'Копировать из терминала',
|
||||
'settings.shortcuts.binding.paste': 'Вставить в терминал',
|
||||
@@ -507,9 +517,13 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.binding.select-all': 'Выделить всё содержимое терминала',
|
||||
'settings.shortcuts.binding.clear-buffer': 'Очистить буфер терминала',
|
||||
'settings.shortcuts.binding.search-terminal': 'Открыть поиск по терминалу',
|
||||
'settings.shortcuts.binding.increase-terminal-font-size': 'Увеличить размер шрифта терминала',
|
||||
'settings.shortcuts.binding.decrease-terminal-font-size': 'Уменьшить размер шрифта терминала',
|
||||
'settings.shortcuts.binding.reset-terminal-font-size': 'Сбросить размер шрифта терминала',
|
||||
'settings.shortcuts.binding.move-focus': 'Переместить фокус между разделёнными окнами',
|
||||
'settings.shortcuts.binding.split-horizontal': 'Горизонтальное разделение',
|
||||
'settings.shortcuts.binding.split-vertical': 'Вертикальное разделение',
|
||||
'settings.shortcuts.binding.toggle-pane-zoom': 'Переключить масштаб панели',
|
||||
'settings.shortcuts.binding.open-hosts': 'Открыть список хостов',
|
||||
'settings.shortcuts.binding.open-local': 'Открыть локальный терминал',
|
||||
'settings.shortcuts.binding.open-sftp': 'Открыть SFTP',
|
||||
|
||||
@@ -186,6 +186,9 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.moveTo.pathNotFound': 'Каталог не найден или недоступен',
|
||||
'sftp.context.download': 'Скачать',
|
||||
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
|
||||
'sftp.copyCurrentPath': 'Копировать текущий путь',
|
||||
'sftp.copyCurrentPath.success': 'Текущий путь скопирован',
|
||||
'sftp.copyCurrentPath.error': 'Не удалось скопировать текущий путь',
|
||||
'sftp.viewMode.label': 'Режим просмотра',
|
||||
'sftp.viewMode.list': 'Список',
|
||||
'sftp.viewMode.tree': 'Дерево',
|
||||
@@ -499,7 +502,52 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.section.portCredentials': 'Порт и учётные данные',
|
||||
'hostDetails.section.appearance': 'Внешний вид',
|
||||
'hostDetails.distro.title': 'Дистрибутив Linux',
|
||||
'hostDetails.distro.desc': 'Автоопределение при подключении или ручное переопределение значка дистрибутива.',
|
||||
'hostDetails.distro.desc': 'Управляет автоматическим значком хоста. Свой значок хоста переопределяет это отображение.',
|
||||
'hostDetails.icon.title': 'Значок хоста',
|
||||
'hostDetails.icon.desc': 'Используйте автоматический значок дистрибутива с отдельным цветом или выберите встроенный значок.',
|
||||
'hostDetails.icon.mode.auto': 'Авто',
|
||||
'hostDetails.icon.mode.custom': 'Свой',
|
||||
'hostDetails.icon.reset': 'Сбросить значок',
|
||||
'hostDetails.icon.showLibrary': 'Показать библиотеку значков',
|
||||
'hostDetails.icon.hideLibrary': 'Скрыть библиотеку значков',
|
||||
'hostDetails.icon.autoUsesDistro': 'Использует значок дистрибутива Linux и выбранный цвет для этого хоста.',
|
||||
'hostDetails.icon.customOverridesDistro': 'Встроенный значок заменяет значок дистрибутива Linux для этого хоста.',
|
||||
'hostDetails.icon.option.server': 'Сервер',
|
||||
'hostDetails.icon.option.terminal': 'Терминал',
|
||||
'hostDetails.icon.option.database': 'База данных',
|
||||
'hostDetails.icon.option.cloud': 'Облако',
|
||||
'hostDetails.icon.option.router': 'Маршрутизатор',
|
||||
'hostDetails.icon.option.shield': 'Защита',
|
||||
'hostDetails.icon.option.code': 'Код',
|
||||
'hostDetails.icon.option.box': 'Узел',
|
||||
'hostDetails.icon.option.globe': 'Глобус',
|
||||
'hostDetails.icon.option.cpu': 'CPU',
|
||||
'hostDetails.icon.option.hard-drive': 'Хранилище',
|
||||
'hostDetails.icon.option.network': 'Сеть',
|
||||
'hostDetails.icon.option.wifi': 'Wi-Fi',
|
||||
'hostDetails.icon.option.lock': 'Замок',
|
||||
'hostDetails.icon.option.key': 'Ключ',
|
||||
'hostDetails.icon.option.monitor': 'Монитор',
|
||||
'hostDetails.icon.option.container': 'Контейнер',
|
||||
'hostDetails.icon.option.activity': 'Активность',
|
||||
'hostDetails.icon.option.zap': 'Быстрый',
|
||||
'hostDetails.icon.option.server-cog': 'Настройки сервера',
|
||||
'hostDetails.icon.color.blue': 'Синий',
|
||||
'hostDetails.icon.color.green': 'Зеленый',
|
||||
'hostDetails.icon.color.red': 'Красный',
|
||||
'hostDetails.icon.color.amber': 'Янтарный',
|
||||
'hostDetails.icon.color.purple': 'Фиолетовый',
|
||||
'hostDetails.icon.color.cyan': 'Голубой',
|
||||
'hostDetails.icon.color.orange': 'Оранжевый',
|
||||
'hostDetails.icon.color.slate': 'Серый',
|
||||
'hostDetails.icon.color.violet': 'Фиолетово-синий',
|
||||
'hostDetails.icon.color.pink': 'Розовый',
|
||||
'hostDetails.icon.color.rose': 'Розово-красный',
|
||||
'hostDetails.icon.color.lime': 'Лаймовый',
|
||||
'hostDetails.icon.color.teal': 'Бирюзовый',
|
||||
'hostDetails.icon.color.sky': 'Небесный',
|
||||
'hostDetails.icon.color.indigo': 'Индиго',
|
||||
'hostDetails.icon.color.zinc': 'Цинковый',
|
||||
'hostDetails.distro.mode': 'Источник',
|
||||
'hostDetails.distro.mode.auto': 'Автоопределение',
|
||||
'hostDetails.distro.mode.manual': 'Ручное переопределение',
|
||||
|
||||
77
application/i18n/locales/settingsLocales.test.ts
Normal file
77
application/i18n/locales/settingsLocales.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
|
||||
import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import ru from "./ru.ts";
|
||||
|
||||
const LOCALIZED_SETTINGS_LOCALES = [
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "ru", messages: ru },
|
||||
];
|
||||
|
||||
test("localized settings include names for every default shortcut", () => {
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = DEFAULT_KEY_BINDINGS
|
||||
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
|
||||
.filter((key) => !locale.messages[key]);
|
||||
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing shortcut labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include workspace focus indicator labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.section.workspaceFocus",
|
||||
"settings.terminal.workspaceFocus.style",
|
||||
"settings.terminal.workspaceFocus.style.desc",
|
||||
"settings.terminal.workspaceFocus.dim",
|
||||
"settings.terminal.workspaceFocus.border",
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing workspace focus labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include terminal font weight option labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.font.weight.thin",
|
||||
"settings.terminal.font.weight.extraLight",
|
||||
"settings.terminal.font.weight.light",
|
||||
"settings.terminal.font.weight.normal",
|
||||
"settings.terminal.font.weight.medium",
|
||||
"settings.terminal.font.weight.semiBold",
|
||||
"settings.terminal.font.weight.bold",
|
||||
"settings.terminal.font.weight.extraBold",
|
||||
"settings.terminal.font.weight.black",
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing font weight labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized vault messages include host icon labels", () => {
|
||||
const keys = [
|
||||
"hostDetails.icon.title",
|
||||
"hostDetails.icon.desc",
|
||||
"hostDetails.icon.mode.auto",
|
||||
"hostDetails.icon.mode.custom",
|
||||
"hostDetails.icon.reset",
|
||||
"hostDetails.icon.showLibrary",
|
||||
"hostDetails.icon.hideLibrary",
|
||||
"hostDetails.icon.autoUsesDistro",
|
||||
"hostDetails.icon.customOverridesDistro",
|
||||
...HOST_ICON_IDS.map((id) => `hostDetails.icon.option.${id}`),
|
||||
...HOST_ICON_COLORS.map((color) => `hostDetails.icon.color.${color.id}`),
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing host icon labels`);
|
||||
}
|
||||
});
|
||||
@@ -573,6 +573,9 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
|
||||
'sftp.context.download': '下载',
|
||||
'sftp.context.copyToOtherPane': '复制到另一侧',
|
||||
'sftp.copyCurrentPath': '复制当前路径',
|
||||
'sftp.copyCurrentPath.success': '已复制当前路径',
|
||||
'sftp.copyCurrentPath.error': '无法复制当前路径',
|
||||
'sftp.viewMode.label': '视图模式',
|
||||
'sftp.viewMode.list': '列表视图',
|
||||
'sftp.viewMode.tree': '树形视图',
|
||||
|
||||
@@ -190,6 +190,15 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.font.size.desc': '终端文字大小',
|
||||
'settings.terminal.font.weight': '字重',
|
||||
'settings.terminal.font.weight.desc': '常规文本字重 (100-900)',
|
||||
'settings.terminal.font.weight.thin': '极细',
|
||||
'settings.terminal.font.weight.extraLight': '特细',
|
||||
'settings.terminal.font.weight.light': '细',
|
||||
'settings.terminal.font.weight.normal': '常规',
|
||||
'settings.terminal.font.weight.medium': '中等',
|
||||
'settings.terminal.font.weight.semiBold': '半粗',
|
||||
'settings.terminal.font.weight.bold': '粗',
|
||||
'settings.terminal.font.weight.extraBold': '特粗',
|
||||
'settings.terminal.font.weight.black': '黑体',
|
||||
'settings.terminal.font.weightBold': '粗体字重',
|
||||
'settings.terminal.font.weightBold.desc': '粗体文本字重 (100-900)',
|
||||
'settings.terminal.font.linePadding': '行间距',
|
||||
@@ -328,6 +337,13 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
|
||||
'settings.terminal.rendering.auto': '自动',
|
||||
|
||||
// Settings > Terminal > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': '工作区焦点提示',
|
||||
'settings.terminal.workspaceFocus.style': '焦点提示样式',
|
||||
'settings.terminal.workspaceFocus.style.desc': '在分屏视图中如何标识当前聚焦的窗格。',
|
||||
'settings.terminal.workspaceFocus.dim': '淡化未聚焦窗格',
|
||||
'settings.terminal.workspaceFocus.border': '为聚焦窗格显示边框',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
'settings.terminal.section.autocomplete': '自动补全',
|
||||
'settings.terminal.autocomplete.enabled': '启用自动补全',
|
||||
@@ -362,18 +378,25 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.next-tab': '下一个标签页',
|
||||
'settings.shortcuts.binding.prev-tab': '上一个标签页',
|
||||
'settings.shortcuts.binding.close-tab': '关闭标签页',
|
||||
'settings.shortcuts.binding.close-session': '关闭会话窗格',
|
||||
'settings.shortcuts.binding.new-tab': '新建本地标签页',
|
||||
'settings.shortcuts.binding.copy': '从终端复制',
|
||||
'settings.shortcuts.binding.paste': '粘贴到终端',
|
||||
'settings.shortcuts.binding.paste-selection': '将选区粘贴到终端',
|
||||
'settings.shortcuts.binding.select-all': '全选终端内容',
|
||||
'settings.shortcuts.binding.clear-buffer': '清空终端缓冲区',
|
||||
'settings.shortcuts.binding.search-terminal': '打开终端搜索',
|
||||
'settings.shortcuts.binding.increase-terminal-font-size': '增大终端字号',
|
||||
'settings.shortcuts.binding.decrease-terminal-font-size': '减小终端字号',
|
||||
'settings.shortcuts.binding.reset-terminal-font-size': '重置终端字号',
|
||||
'settings.shortcuts.binding.move-focus': '在分屏间移动焦点',
|
||||
'settings.shortcuts.binding.split-horizontal': '水平分屏',
|
||||
'settings.shortcuts.binding.split-vertical': '垂直分屏',
|
||||
'settings.shortcuts.binding.toggle-pane-zoom': '切换窗格缩放',
|
||||
'settings.shortcuts.binding.open-hosts': '打开主机列表',
|
||||
'settings.shortcuts.binding.open-local': '打开本地终端',
|
||||
'settings.shortcuts.binding.open-sftp': '打开 SFTP',
|
||||
'settings.shortcuts.binding.open-settings': '打开设置',
|
||||
'settings.shortcuts.binding.port-forwarding': '打开端口转发',
|
||||
'settings.shortcuts.binding.command-palette': '打开命令面板',
|
||||
'settings.shortcuts.binding.quick-switch': '快速切换',
|
||||
@@ -389,6 +412,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.sftp-delete': '删除文件',
|
||||
'settings.shortcuts.binding.sftp-refresh': '刷新',
|
||||
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
|
||||
'settings.shortcuts.binding.sftp-open': '打开文件 / 进入目录',
|
||||
'settings.shortcuts.binding.sftp-go-parent': '转到上级目录',
|
||||
'settings.shortcuts.binding.sftp-navigate-to': '转到选中的目录',
|
||||
|
||||
// Host Details (sub-panels)
|
||||
'hostDetails.proxyPanel.title': '通过 HTTP/SOCKS5/命令代理',
|
||||
|
||||
@@ -45,7 +45,52 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.section.portCredentials': '端口与凭据',
|
||||
'hostDetails.section.appearance': '外观',
|
||||
'hostDetails.distro.title': 'Linux 发行版',
|
||||
'hostDetails.distro.desc': '可在连接后自动探测,也可以手动覆盖图标所用的发行版。',
|
||||
'hostDetails.distro.desc': '控制自动主机图标。自定义主机图标会覆盖此显示。',
|
||||
'hostDetails.icon.title': '主机图标',
|
||||
'hostDetails.icon.desc': '使用自动发行版图标并可单独改色,或选择内置图标。',
|
||||
'hostDetails.icon.mode.auto': '自动',
|
||||
'hostDetails.icon.mode.custom': '自定义',
|
||||
'hostDetails.icon.reset': '重置主机图标',
|
||||
'hostDetails.icon.showLibrary': '展开图标库',
|
||||
'hostDetails.icon.hideLibrary': '收起图标库',
|
||||
'hostDetails.icon.autoUsesDistro': '使用 Linux 发行版图标和所选颜色显示此主机。',
|
||||
'hostDetails.icon.customOverridesDistro': '内置图标会替换此主机的 Linux 发行版图标。',
|
||||
'hostDetails.icon.option.server': '服务器',
|
||||
'hostDetails.icon.option.terminal': '终端',
|
||||
'hostDetails.icon.option.database': '数据库',
|
||||
'hostDetails.icon.option.cloud': '云主机',
|
||||
'hostDetails.icon.option.router': '路由器',
|
||||
'hostDetails.icon.option.shield': '安全',
|
||||
'hostDetails.icon.option.code': '代码',
|
||||
'hostDetails.icon.option.box': '节点',
|
||||
'hostDetails.icon.option.globe': '公网',
|
||||
'hostDetails.icon.option.cpu': '计算',
|
||||
'hostDetails.icon.option.hard-drive': '存储',
|
||||
'hostDetails.icon.option.network': '网络',
|
||||
'hostDetails.icon.option.wifi': '无线',
|
||||
'hostDetails.icon.option.lock': '锁定',
|
||||
'hostDetails.icon.option.key': '密钥',
|
||||
'hostDetails.icon.option.monitor': '显示器',
|
||||
'hostDetails.icon.option.container': '容器',
|
||||
'hostDetails.icon.option.activity': '活动',
|
||||
'hostDetails.icon.option.zap': '高速',
|
||||
'hostDetails.icon.option.server-cog': '服务器设置',
|
||||
'hostDetails.icon.color.blue': '蓝色',
|
||||
'hostDetails.icon.color.green': '绿色',
|
||||
'hostDetails.icon.color.red': '红色',
|
||||
'hostDetails.icon.color.amber': '琥珀色',
|
||||
'hostDetails.icon.color.purple': '紫色',
|
||||
'hostDetails.icon.color.cyan': '青色',
|
||||
'hostDetails.icon.color.orange': '橙色',
|
||||
'hostDetails.icon.color.slate': '石板灰',
|
||||
'hostDetails.icon.color.violet': '紫罗兰',
|
||||
'hostDetails.icon.color.pink': '粉色',
|
||||
'hostDetails.icon.color.rose': '玫瑰红',
|
||||
'hostDetails.icon.color.lime': '青柠',
|
||||
'hostDetails.icon.color.teal': '蓝绿色',
|
||||
'hostDetails.icon.color.sky': '天蓝',
|
||||
'hostDetails.icon.color.indigo': '靛蓝',
|
||||
'hostDetails.icon.color.zinc': '锌灰',
|
||||
'hostDetails.distro.mode': '来源',
|
||||
'hostDetails.distro.mode.auto': '自动探测',
|
||||
'hostDetails.distro.mode.manual': '手动覆盖',
|
||||
|
||||
64
components/ConnectionLogsManager.test.tsx
Normal file
64
components/ConnectionLogsManager.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
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 { ConnectionLog } from "../types.ts";
|
||||
import ConnectionLogsManager from "./ConnectionLogsManager.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const baseLog: ConnectionLog = {
|
||||
id: "log-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "Database",
|
||||
hostname: "db.example.com",
|
||||
username: "root",
|
||||
protocol: "ssh",
|
||||
hostOs: "linux",
|
||||
hostDistro: "ubuntu",
|
||||
startTime: 1_700_000_000_000,
|
||||
localUsername: "alice",
|
||||
localHostname: "workstation",
|
||||
saved: false,
|
||||
};
|
||||
|
||||
const renderLogs = (log: ConnectionLog) =>
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>
|
||||
<ConnectionLogsManager
|
||||
logs={[log]}
|
||||
hosts={[]}
|
||||
onToggleSaved={() => {}}
|
||||
onDelete={() => {}}
|
||||
onClearUnsaved={() => {}}
|
||||
onOpenLogView={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
test("ConnectionLogsManager renders saved custom host icon snapshots", () => {
|
||||
const markup = renderLogs({
|
||||
...baseLog,
|
||||
hostIconMode: "custom",
|
||||
hostIconId: "database",
|
||||
hostIconColor: "blue",
|
||||
});
|
||||
|
||||
assert.match(markup, /background-color:#2563EB/i);
|
||||
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
|
||||
});
|
||||
|
||||
test("ConnectionLogsManager renders saved distro icon snapshots with custom colors", () => {
|
||||
const markup = renderLogs({
|
||||
...baseLog,
|
||||
hostIconMode: "auto",
|
||||
hostIconColor: "violet",
|
||||
});
|
||||
|
||||
assert.match(markup, /background-color:#7C3AED/i);
|
||||
assert.match(markup, /src="\/distro\/ubuntu.svg"/);
|
||||
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { resolveHostIconAppearance } from "../domain/hostIcon";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConnectionLog, Host } from "../types";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
@@ -67,7 +68,12 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const isLocal = log.protocol === "local" || log.hostname === "localhost";
|
||||
const isSerial = log.protocol === "serial";
|
||||
const hasPersistedHostIcon = !isLocal && !isSerial && !!log.hostDistro;
|
||||
const customHostIcon = resolveHostIconAppearance({
|
||||
iconMode: log.hostIconMode,
|
||||
iconId: log.hostIconId,
|
||||
iconColor: log.hostIconColor,
|
||||
});
|
||||
const hasPersistedHostIcon = !isLocal && !isSerial && (!!log.hostDistro || !!customHostIcon);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -101,6 +107,9 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
|
||||
os: log.hostOs ?? "linux",
|
||||
distro: log.hostDistro,
|
||||
distroMode: "auto",
|
||||
iconMode: log.hostIconMode,
|
||||
iconId: log.hostIconId,
|
||||
iconColor: log.hostIconColor,
|
||||
}}
|
||||
fallback={(log.hostOs ?? "linux")[0].toUpperCase()}
|
||||
size="log"
|
||||
|
||||
49
components/DistroAvatar.test.tsx
Normal file
49
components/DistroAvatar.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { DistroAvatar } from "./DistroAvatar.tsx";
|
||||
import type { Host } from "../types.ts";
|
||||
|
||||
const baseHost: Pick<Host, "distro" | "manualDistro" | "distroMode" | "os" | "protocol" | "iconMode" | "iconId" | "iconColor"> = {
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
};
|
||||
|
||||
test("DistroAvatar renders custom host icon before distro color", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
host={{ ...baseHost, distro: "ubuntu", iconMode: "custom", iconId: "database", iconColor: "blue" }}
|
||||
fallback="DB"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /background-color:#2563EB/i);
|
||||
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
|
||||
});
|
||||
|
||||
test("DistroAvatar keeps serial hosts on the USB icon", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
host={{ ...baseHost, protocol: "serial", iconMode: "custom", iconId: "database", iconColor: "blue" }}
|
||||
fallback="S"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /bg-amber-600/);
|
||||
assert.doesNotMatch(markup, /background-color:#2563EB/i);
|
||||
});
|
||||
|
||||
test("DistroAvatar keeps distro icon and applies custom palette color when icon mode is automatic", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DistroAvatar
|
||||
host={{ ...baseHost, distro: "ubuntu", iconMode: "auto", iconId: "database", iconColor: "blue" }}
|
||||
fallback="U"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /background-color:#2563EB/i);
|
||||
assert.match(markup, /src="\/distro\/ubuntu.svg"/);
|
||||
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Server, Usb } from "lucide-react";
|
||||
import React, { memo } from "react";
|
||||
import { getEffectiveHostDistro } from "../domain/host";
|
||||
import { resolveHostIconAppearance, resolveHostIconColorAppearance } from "../domain/hostIcon";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host } from "../types";
|
||||
import { renderHostIconGlyph } from "./hostIconRenderer";
|
||||
|
||||
export const DISTRO_LOGOS: Record<string, string> = {
|
||||
ubuntu: "/distro/ubuntu.svg",
|
||||
@@ -71,7 +73,7 @@ export const DISTRO_COLORS: Record<string, string> = {
|
||||
|
||||
type DistroAvatarProps = {
|
||||
host: Pick<Host, "distro" | "manualDistro" | "distroMode" | "os"> &
|
||||
Partial<Pick<Host, "protocol">>;
|
||||
Partial<Pick<Host, "protocol" | "iconMode" | "iconId" | "iconColor">>;
|
||||
fallback: string;
|
||||
className?: string;
|
||||
/** xs matches top tab bar icons (h-4 rounded rect) */
|
||||
@@ -125,15 +127,33 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const customAppearance = resolveHostIconAppearance(host);
|
||||
const customColor = resolveHostIconColorAppearance(host);
|
||||
if (customAppearance) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded flex items-center justify-center text-white",
|
||||
containerClass,
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundColor: customAppearance.colorHex }}
|
||||
>
|
||||
{renderHostIconGlyph(customAppearance.iconId, iconSize)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (logo && !errored) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded flex items-center justify-center overflow-hidden",
|
||||
containerClass,
|
||||
bg,
|
||||
!customColor && bg,
|
||||
className,
|
||||
)}
|
||||
style={customColor ? { backgroundColor: customColor.colorHex } : undefined}
|
||||
>
|
||||
<img
|
||||
src={logo}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRoun
|
||||
import type { Host } from "../types";
|
||||
import { cn } from "../lib/utils";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { HostIconPicker } from "./HostIconPicker";
|
||||
import { Button } from "./ui/button";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
import { HostDetailsSection, HostDetailsSettingRow } from "./host-details";
|
||||
@@ -71,6 +72,28 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
</div>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<DistroAvatar host={form as Host} fallback="H" size="sm" />}
|
||||
title={t("hostDetails.icon.title")}
|
||||
hint={t("hostDetails.icon.desc")}
|
||||
>
|
||||
<HostIconPicker
|
||||
iconMode={form.iconMode}
|
||||
iconId={form.iconId}
|
||||
iconColor={form.iconColor}
|
||||
onChange={(next) => {
|
||||
update("iconMode", next.iconMode);
|
||||
update("iconId", next.iconId);
|
||||
update("iconColor", next.iconColor);
|
||||
}}
|
||||
onReset={() => {
|
||||
update("iconMode", undefined);
|
||||
update("iconId", undefined);
|
||||
update("iconColor", undefined);
|
||||
}}
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
|
||||
<HostDetailsSection
|
||||
icon={<KeyRound size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.portCredentials")}
|
||||
|
||||
@@ -296,3 +296,19 @@ test("HostDetailsPanel does not offer to disable telnet when telnet is the prima
|
||||
assert.ok(telnetHeader);
|
||||
assert.doesNotMatch(telnetHeader[0], /hover:text-destructive/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel shows host icon customization in the connection settings", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
proxyProfileId: undefined,
|
||||
iconMode: "custom",
|
||||
iconId: "database",
|
||||
iconColor: "blue",
|
||||
});
|
||||
|
||||
assert.match(markup, /Host Icon/);
|
||||
assert.match(markup, /Database/);
|
||||
assert.match(markup, /Violet/);
|
||||
assert.match(markup, /Built-in icon replaces Linux Distribution/);
|
||||
assert.match(markup, /IP or Hostname/);
|
||||
});
|
||||
|
||||
70
components/HostIconPicker.test.tsx
Normal file
70
components/HostIconPicker.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
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 { HostIconPicker } from "./HostIconPicker.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const renderPicker = (props: Partial<React.ComponentProps<typeof HostIconPicker>> = {}) =>
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>
|
||||
<HostIconPicker
|
||||
iconMode={props.iconMode}
|
||||
iconId={props.iconId}
|
||||
iconColor={props.iconColor}
|
||||
onChange={() => {}}
|
||||
onReset={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
test("HostIconPicker renders automatic mode without selected custom defaults", () => {
|
||||
const markup = renderPicker();
|
||||
|
||||
assert.match(markup, /Automatic/);
|
||||
assert.doesNotMatch(markup, /aria-pressed="true"[^>]*Database/);
|
||||
});
|
||||
|
||||
test("HostIconPicker renders custom choices and reset when custom", () => {
|
||||
const markup = renderPicker({ iconMode: "custom", iconId: "database", iconColor: "blue" });
|
||||
|
||||
assert.match(markup, /Database/);
|
||||
assert.match(markup, /Globe/);
|
||||
assert.match(markup, /Show icon library/);
|
||||
assert.doesNotMatch(markup, /Server settings/);
|
||||
assert.match(markup, /grid-cols-5/);
|
||||
assert.match(markup, /Blue/);
|
||||
assert.match(markup, /Reset/);
|
||||
assert.match(markup, /Built-in icon replaces Linux Distribution for this host/);
|
||||
});
|
||||
|
||||
test("HostIconPicker shows two rows of color swatches in automatic mode", () => {
|
||||
const markup = renderPicker({ iconMode: "auto", iconColor: "violet" });
|
||||
|
||||
assert.match(markup, /Violet/);
|
||||
assert.match(markup, /grid-cols-8/);
|
||||
assert.match(markup, /Use Linux Distribution icon and selected color/);
|
||||
});
|
||||
|
||||
test("HostIconPicker does not expose image upload", () => {
|
||||
const markup = renderPicker({ iconMode: "custom", iconId: "database", iconColor: "blue" });
|
||||
|
||||
assert.doesNotMatch(markup, /upload/i);
|
||||
assert.doesNotMatch(markup, /choose file/i);
|
||||
});
|
||||
|
||||
test("HostIconPicker normalizes invalid incoming custom values only for editing", () => {
|
||||
const markup = renderPicker({
|
||||
iconMode: "custom",
|
||||
iconId: "bad" as React.ComponentProps<typeof HostIconPicker>["iconId"],
|
||||
iconColor: "bad" as React.ComponentProps<typeof HostIconPicker>["iconColor"],
|
||||
});
|
||||
|
||||
assert.match(markup, /Server/);
|
||||
assert.match(markup, /Blue/);
|
||||
assert.match(markup, /Built-in icon replaces Linux Distribution/);
|
||||
});
|
||||
164
components/HostIconPicker.tsx
Normal file
164
components/HostIconPicker.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import {
|
||||
DEFAULT_HOST_ICON_COLOR,
|
||||
DEFAULT_HOST_ICON_ID,
|
||||
HOST_ICON_COLORS,
|
||||
HOST_ICON_IDS,
|
||||
isHostIconColorId,
|
||||
isHostIconId,
|
||||
normalizeHostIconSelection,
|
||||
} from "../domain/hostIcon";
|
||||
import type { HostIconColorId, HostIconId, HostIconMode } from "../domain/models";
|
||||
import { cn } from "../lib/utils";
|
||||
import { renderHostIconGlyph } from "./hostIconRenderer";
|
||||
import { Button } from "./ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
type HostIconPickerProps = {
|
||||
iconMode?: HostIconMode;
|
||||
iconId?: HostIconId;
|
||||
iconColor?: HostIconColorId;
|
||||
onChange: (next: { iconMode?: HostIconMode; iconId?: HostIconId; iconColor?: HostIconColorId }) => void;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
export const HostIconPicker: React.FC<HostIconPickerProps> = ({
|
||||
iconMode,
|
||||
iconId,
|
||||
iconColor,
|
||||
onChange,
|
||||
onReset,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const custom = iconMode === "custom";
|
||||
const normalizedSelection = normalizeHostIconSelection({ iconMode, iconId, iconColor });
|
||||
const selectedIconId = custom && isHostIconId(normalizedSelection.iconId)
|
||||
? normalizedSelection.iconId
|
||||
: DEFAULT_HOST_ICON_ID;
|
||||
const hasCustomColor = isHostIconColorId(normalizedSelection.iconColor);
|
||||
const selectedColor = hasCustomColor ? normalizedSelection.iconColor : DEFAULT_HOST_ICON_COLOR;
|
||||
const selectedColorHex =
|
||||
HOST_ICON_COLORS.find((color) => color.id === selectedColor)?.hex || HOST_ICON_COLORS[0].hex;
|
||||
|
||||
const setCustom = () => onChange({ iconMode: "custom", iconId: selectedIconId, iconColor: selectedColor });
|
||||
const updateIcon = (nextIconId: HostIconId) =>
|
||||
onChange({ iconMode: "custom", iconId: nextIconId, iconColor: selectedColor });
|
||||
const updateColor = (nextColor: HostIconColorId) => {
|
||||
if (custom) {
|
||||
onChange({ iconMode: "custom", iconId: selectedIconId, iconColor: nextColor });
|
||||
return;
|
||||
}
|
||||
onChange({ iconMode: "auto", iconColor: nextColor });
|
||||
};
|
||||
const visibleIconIds = custom && !expanded ? HOST_ICON_IDS.slice(0, 10) : HOST_ICON_IDS;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={custom ? "ghost" : "secondary"}
|
||||
size="sm"
|
||||
className="h-8 flex-1"
|
||||
onClick={onReset}
|
||||
>
|
||||
{t("hostDetails.icon.mode.auto")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={custom ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-8 flex-1"
|
||||
onClick={setCustom}
|
||||
>
|
||||
{t("hostDetails.icon.mode.custom")}
|
||||
</Button>
|
||||
{(custom || hasCustomColor) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={onReset}
|
||||
aria-label={t("hostDetails.icon.reset")}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{custom && (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{visibleIconIds.map((optionIconId) => {
|
||||
const selected = selectedIconId === optionIconId;
|
||||
return (
|
||||
<Tooltip key={optionIconId}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(`hostDetails.icon.option.${optionIconId}`)}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"flex h-9 items-center justify-center rounded-md border text-muted-foreground transition-colors hover:bg-secondary",
|
||||
selected ? "border-primary bg-primary/10 text-primary" : "border-border/60 bg-background/60",
|
||||
)}
|
||||
onClick={() => updateIcon(optionIconId)}
|
||||
>
|
||||
{renderHostIconGlyph(optionIconId, "h-4 w-4")}
|
||||
<span className="sr-only">{t(`hostDetails.icon.option.${optionIconId}`)}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t(`hostDetails.icon.option.${optionIconId}`)}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-full text-xs"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{t(expanded ? "hostDetails.icon.hideLibrary" : "hostDetails.icon.showLibrary")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-8 gap-2">
|
||||
{HOST_ICON_COLORS.map((color) => {
|
||||
const selected = hasCustomColor && selectedColor === color.id;
|
||||
return (
|
||||
<Tooltip key={color.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(`hostDetails.icon.color.${color.id}`)}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"h-7 rounded-md border transition-transform hover:scale-105",
|
||||
selected ? "border-primary ring-2 ring-primary/30" : "border-border/60",
|
||||
)}
|
||||
style={{ backgroundColor: color.hex }}
|
||||
onClick={() => updateColor(color.id)}
|
||||
>
|
||||
<span className="sr-only">{t(`hostDetails.icon.color.${color.id}`)}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t(`hostDetails.icon.color.${color.id}`)}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-secondary/40 px-2.5 py-2 text-xs text-muted-foreground">
|
||||
<span className="h-4 w-4 rounded" style={{ backgroundColor: hasCustomColor ? selectedColorHex : undefined }} />
|
||||
<span>{t(custom ? "hostDetails.icon.customOverridesDistro" : "hostDetails.icon.autoUsesDistro")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -103,6 +103,15 @@ test("quick switcher plus button exposes a custom CSS hook", () => {
|
||||
assert.match(topTabsSource, /data-section="top-tabs-quick-switcher-toggle"/);
|
||||
});
|
||||
|
||||
test("SessionTabIcon checks custom host icon appearance before distro logos", () => {
|
||||
const source = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /resolveHostIconAppearance\(host\)/);
|
||||
assert.ok(
|
||||
source.indexOf("resolveHostIconAppearance(host)") < source.indexOf("getEffectiveHostDistro(host)"),
|
||||
"custom host icon should be checked before distro fallback",
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace session drag data is recognized with a dedicated drag type", () => {
|
||||
const data = new Map([
|
||||
[WORKSPACE_SESSION_DRAG_TYPE, "session-1"],
|
||||
|
||||
75
components/hostIconRenderer.tsx
Normal file
75
components/hostIconRenderer.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Activity,
|
||||
Box,
|
||||
Cloud,
|
||||
Code2,
|
||||
Container,
|
||||
Cpu,
|
||||
Database,
|
||||
Globe2,
|
||||
HardDrive,
|
||||
KeyRound,
|
||||
Lock,
|
||||
Monitor,
|
||||
Network,
|
||||
Router,
|
||||
Server,
|
||||
ServerCog,
|
||||
Shield,
|
||||
SquareTerminal,
|
||||
Wifi,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
import type { HostIconId } from "../domain/models";
|
||||
|
||||
const HOST_ICON_COMPONENTS = {
|
||||
server: Server,
|
||||
terminal: SquareTerminal,
|
||||
database: Database,
|
||||
cloud: Cloud,
|
||||
router: Router,
|
||||
shield: Shield,
|
||||
code: Code2,
|
||||
box: Box,
|
||||
globe: Globe2,
|
||||
cpu: Cpu,
|
||||
"hard-drive": HardDrive,
|
||||
network: Network,
|
||||
wifi: Wifi,
|
||||
lock: Lock,
|
||||
key: KeyRound,
|
||||
monitor: Monitor,
|
||||
container: Container,
|
||||
activity: Activity,
|
||||
zap: Zap,
|
||||
"server-cog": ServerCog,
|
||||
} as const satisfies Record<HostIconId, React.ComponentType<{ className?: string; size?: number }>>;
|
||||
|
||||
export const HOST_ICON_LABEL_KEYS: Record<HostIconId, string> = {
|
||||
server: "hostDetails.icon.option.server",
|
||||
terminal: "hostDetails.icon.option.terminal",
|
||||
database: "hostDetails.icon.option.database",
|
||||
cloud: "hostDetails.icon.option.cloud",
|
||||
router: "hostDetails.icon.option.router",
|
||||
shield: "hostDetails.icon.option.shield",
|
||||
code: "hostDetails.icon.option.code",
|
||||
box: "hostDetails.icon.option.box",
|
||||
globe: "hostDetails.icon.option.globe",
|
||||
cpu: "hostDetails.icon.option.cpu",
|
||||
"hard-drive": "hostDetails.icon.option.hard-drive",
|
||||
network: "hostDetails.icon.option.network",
|
||||
wifi: "hostDetails.icon.option.wifi",
|
||||
lock: "hostDetails.icon.option.lock",
|
||||
key: "hostDetails.icon.option.key",
|
||||
monitor: "hostDetails.icon.option.monitor",
|
||||
container: "hostDetails.icon.option.container",
|
||||
activity: "hostDetails.icon.option.activity",
|
||||
zap: "hostDetails.icon.option.zap",
|
||||
"server-cog": "hostDetails.icon.option.server-cog",
|
||||
};
|
||||
|
||||
export const renderHostIconGlyph = (iconId: HostIconId, className?: string): React.ReactNode => {
|
||||
const Icon = HOST_ICON_COMPONENTS[iconId] || Server;
|
||||
return <Icon className={className} />;
|
||||
};
|
||||
@@ -71,7 +71,7 @@ export const Select: React.FC<SelectProps> = ({
|
||||
</SelectPrimitive.Trigger>
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
className="z-[200000] max-h-80 w-max max-w-[var(--radix-select-content-available-width)] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
|
||||
className="z-[200000] max-h-80 w-max max-w-[min(24rem,var(--radix-select-content-available-width))] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
style={{ minWidth: "max(12rem, var(--radix-select-trigger-width))" }}
|
||||
@@ -84,7 +84,7 @@ export const Select: React.FC<SelectProps> = ({
|
||||
<SelectPrimitive.Item
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className="relative flex w-full min-w-max cursor-default select-none items-center whitespace-nowrap rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
className="relative flex w-full min-w-0 cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
@@ -92,7 +92,7 @@ export const Select: React.FC<SelectProps> = ({
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>
|
||||
<span className="flex items-center gap-2 whitespace-nowrap">
|
||||
<span className="flex min-w-0 items-center gap-2 whitespace-normal break-words leading-snug">
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</span>
|
||||
|
||||
@@ -27,6 +27,19 @@ import { resolveFollowedTerminalThemeId, TERMINAL_THEME_AUTO } from "../../../do
|
||||
|
||||
import { KeywordHighlightRulesEditor, ThemePreviewButton } from "./SettingsTerminalTabControls";
|
||||
import { TerminalBehaviorSettings } from "./TerminalBehaviorSettings";
|
||||
|
||||
const FONT_WEIGHT_OPTIONS = [
|
||||
{ value: "100", labelKey: "settings.terminal.font.weight.thin" },
|
||||
{ value: "200", labelKey: "settings.terminal.font.weight.extraLight" },
|
||||
{ value: "300", labelKey: "settings.terminal.font.weight.light" },
|
||||
{ value: "400", labelKey: "settings.terminal.font.weight.normal" },
|
||||
{ value: "500", labelKey: "settings.terminal.font.weight.medium" },
|
||||
{ value: "600", labelKey: "settings.terminal.font.weight.semiBold" },
|
||||
{ value: "700", labelKey: "settings.terminal.font.weight.bold" },
|
||||
{ value: "800", labelKey: "settings.terminal.font.weight.extraBold" },
|
||||
{ value: "900", labelKey: "settings.terminal.font.weight.black" },
|
||||
];
|
||||
|
||||
function SettingsTerminalTab(props: {
|
||||
terminalThemeId: string;
|
||||
setTerminalThemeId: (id: string) => void;
|
||||
@@ -146,6 +159,13 @@ function SettingsTerminalTab(props: {
|
||||
|| TERMINAL_THEMES[0];
|
||||
}, [terminalThemeDarkId, terminalThemeLightId, lightUiThemeId, darkUiThemeId, terminalThemeId, customThemes]);
|
||||
|
||||
const fontWeightOptions = useMemo(() => (
|
||||
FONT_WEIGHT_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: `${option.value} - ${t(option.labelKey)}`,
|
||||
}))
|
||||
), [t]);
|
||||
|
||||
const handleAutocompleteGhostTextChange = useCallback((enabled: boolean) => {
|
||||
updateTerminalSetting("autocompleteGhostText", enabled);
|
||||
if (enabled) {
|
||||
@@ -516,17 +536,7 @@ function SettingsTerminalTab(props: {
|
||||
>
|
||||
<Select
|
||||
value={String(terminalSettings.fontWeight)}
|
||||
options={[
|
||||
{ value: "100", label: "100 - Thin" },
|
||||
{ value: "200", label: "200 - Extra Light" },
|
||||
{ value: "300", label: "300 - Light" },
|
||||
{ value: "400", label: "400 - Normal" },
|
||||
{ value: "500", label: "500 - Medium" },
|
||||
{ value: "600", label: "600 - Semi Bold" },
|
||||
{ value: "700", label: "700 - Bold" },
|
||||
{ value: "800", label: "800 - Extra Bold" },
|
||||
{ value: "900", label: "900 - Black" },
|
||||
]}
|
||||
options={fontWeightOptions}
|
||||
onChange={(v) => updateTerminalSetting("fontWeight", parseInt(v))}
|
||||
className="w-40"
|
||||
/>
|
||||
@@ -538,17 +548,7 @@ function SettingsTerminalTab(props: {
|
||||
>
|
||||
<Select
|
||||
value={String(terminalSettings.fontWeightBold)}
|
||||
options={[
|
||||
{ value: "100", label: "100 - Thin" },
|
||||
{ value: "200", label: "200 - Extra Light" },
|
||||
{ value: "300", label: "300 - Light" },
|
||||
{ value: "400", label: "400 - Normal" },
|
||||
{ value: "500", label: "500 - Medium" },
|
||||
{ value: "600", label: "600 - Semi Bold" },
|
||||
{ value: "700", label: "700 - Bold" },
|
||||
{ value: "800", label: "800 - Extra Bold" },
|
||||
{ value: "900", label: "900 - Black" },
|
||||
]}
|
||||
options={fontWeightOptions}
|
||||
onChange={(v) => updateTerminalSetting("fontWeightBold", parseInt(v))}
|
||||
className="w-40"
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import {
|
||||
getSftpBookmarkButtonLabelKey,
|
||||
getNextSftpViewMode,
|
||||
copySftpCurrentPathToClipboard,
|
||||
getNextSftpToolbarDisplayPath,
|
||||
getSftpViewModeToggleTarget,
|
||||
getSftpViewModeToggleLabelKey,
|
||||
shouldToggleSftpBookmarkFromButton,
|
||||
@@ -139,6 +141,137 @@ test("toolbar renders one view-mode toggle instead of separate list and tree but
|
||||
assert.match(markup, /aria-label="Bookmarked paths"/);
|
||||
});
|
||||
|
||||
test("toolbar exposes copy-current-path action for the active directory", () => {
|
||||
const pane: SftpPane = {
|
||||
id: "pane-1",
|
||||
connection: {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
name: "Example",
|
||||
currentPath: "/var/www/app",
|
||||
homeDir: "/home/app",
|
||||
isLocal: false,
|
||||
},
|
||||
files: [],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
showHiddenFiles: false,
|
||||
transferMutationToken: 0,
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(SftpPaneToolbar, {
|
||||
t: (key: string) => ({
|
||||
"sftp.copyCurrentPath": "Copy current path",
|
||||
"sftp.viewMode.switchToTree": "Switch to tree view",
|
||||
"sftp.bookmark.list": "Bookmarked paths",
|
||||
}[key] ?? key),
|
||||
pane,
|
||||
onNavigateTo: () => {},
|
||||
onSetFilter: () => {},
|
||||
onSetFilenameEncoding: () => {},
|
||||
onRefresh: () => {},
|
||||
showFilterBar: false,
|
||||
setShowFilterBar: () => {},
|
||||
filterInputRef: { current: null },
|
||||
isEditingPath: false,
|
||||
editingPathValue: "",
|
||||
setEditingPathValue: () => {},
|
||||
setShowPathSuggestions: () => {},
|
||||
showPathSuggestions: false,
|
||||
setPathSuggestionIndex: () => {},
|
||||
pathSuggestions: [],
|
||||
pathSuggestionIndex: -1,
|
||||
pathInputRef: { current: null },
|
||||
pathDropdownRef: { current: null },
|
||||
handlePathBlur: () => {},
|
||||
handlePathKeyDown: () => {},
|
||||
handlePathDoubleClick: () => {},
|
||||
handlePathSubmit: () => {},
|
||||
startTransition: (callback: () => void) => callback(),
|
||||
getNextUntitledName: () => "untitled",
|
||||
setNewFileName: () => {},
|
||||
setFileNameError: () => {},
|
||||
setShowNewFileDialog: () => {},
|
||||
setShowNewFolderDialog: () => {},
|
||||
setNewFolderName: () => {},
|
||||
bookmarks: [],
|
||||
isCurrentPathBookmarked: false,
|
||||
onToggleBookmark: () => {},
|
||||
onAddGlobalBookmark: () => {},
|
||||
isCurrentPathGlobalBookmarked: false,
|
||||
onNavigateToBookmark: () => {},
|
||||
onDeleteBookmark: () => {},
|
||||
showHiddenFiles: false,
|
||||
onToggleShowHiddenFiles: () => {},
|
||||
viewMode: "list",
|
||||
onSetViewMode: () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(markup, /aria-label="Copy current path"/);
|
||||
});
|
||||
|
||||
test("copy-current-path action writes the displayed path and reports success", async () => {
|
||||
let copiedText = "";
|
||||
let successMessage = "";
|
||||
|
||||
await copySftpCurrentPathToClipboard({
|
||||
currentPath: "/srv/current",
|
||||
writeText: async (text) => {
|
||||
copiedText = text;
|
||||
},
|
||||
onSuccess: (message) => {
|
||||
successMessage = message;
|
||||
},
|
||||
onError: () => {},
|
||||
t: (key) => ({
|
||||
"sftp.copyCurrentPath.success": "Current path copied",
|
||||
}[key] ?? key),
|
||||
});
|
||||
|
||||
assert.equal(copiedText, "/srv/current");
|
||||
assert.equal(successMessage, "Current path copied");
|
||||
});
|
||||
|
||||
test("copy-current-path action reports clipboard failures", async () => {
|
||||
let errorMessage = "";
|
||||
|
||||
await copySftpCurrentPathToClipboard({
|
||||
currentPath: "/srv/current",
|
||||
writeText: async () => {
|
||||
throw new Error("denied");
|
||||
},
|
||||
onSuccess: () => {},
|
||||
onError: (message) => {
|
||||
errorMessage = message;
|
||||
},
|
||||
t: (key) => ({
|
||||
"sftp.copyCurrentPath.error": "Could not copy current path",
|
||||
}[key] ?? key),
|
||||
});
|
||||
|
||||
assert.equal(errorMessage, "Could not copy current path");
|
||||
});
|
||||
|
||||
test("toolbar display path keeps the previous confirmed path while loading the same connection", () => {
|
||||
assert.equal(
|
||||
getNextSftpToolbarDisplayPath({
|
||||
previousDisplayPath: "/srv/old",
|
||||
previousConnectionId: "conn-1",
|
||||
connectionId: "conn-1",
|
||||
currentPath: "/srv/new",
|
||||
loading: true,
|
||||
}),
|
||||
"/srv/old",
|
||||
);
|
||||
});
|
||||
|
||||
test("bookmark list renders saved paths as selectable rows", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, FolderSync, Globe, Home, Languages, List, ListTree, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
|
||||
import { Bookmark, Check, ClipboardCopy, Eye, EyeOff, FilePlus, Folder, FolderPlus, FolderSync, Globe, Home, Languages, List, ListTree, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
@@ -10,6 +10,7 @@ import { SftpBreadcrumb } from "./SftpBreadcrumb";
|
||||
import type { SftpFilenameEncoding } from "../../types";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import type { SftpBookmark } from "../../domain/models";
|
||||
import { toast } from "../ui/toast";
|
||||
|
||||
type SftpPaneViewMode = "list" | "tree";
|
||||
|
||||
@@ -43,6 +44,46 @@ export const getSftpBookmarkButtonLabelKey = ({
|
||||
? "sftp.bookmark.add"
|
||||
: "sftp.bookmark.list";
|
||||
|
||||
export const copySftpCurrentPathToClipboard = async ({
|
||||
currentPath,
|
||||
writeText,
|
||||
onSuccess,
|
||||
onError,
|
||||
t,
|
||||
}: {
|
||||
currentPath: string;
|
||||
writeText: (text: string) => Promise<void>;
|
||||
onSuccess: (message: string) => void;
|
||||
onError: (message: string) => void;
|
||||
t: (key: string) => string;
|
||||
}) => {
|
||||
if (!currentPath) return;
|
||||
|
||||
try {
|
||||
await writeText(currentPath);
|
||||
onSuccess(t("sftp.copyCurrentPath.success"));
|
||||
} catch {
|
||||
onError(t("sftp.copyCurrentPath.error"));
|
||||
}
|
||||
};
|
||||
|
||||
export const getNextSftpToolbarDisplayPath = ({
|
||||
previousDisplayPath,
|
||||
previousConnectionId,
|
||||
connectionId,
|
||||
currentPath,
|
||||
loading,
|
||||
}: {
|
||||
previousDisplayPath: string;
|
||||
previousConnectionId: string | undefined;
|
||||
connectionId: string | undefined;
|
||||
currentPath: string | undefined;
|
||||
loading: boolean;
|
||||
}): string => {
|
||||
const connectionChanged = connectionId !== previousConnectionId;
|
||||
return connectionChanged || !loading ? currentPath ?? "" : previousDisplayPath;
|
||||
};
|
||||
|
||||
interface SftpPaneToolbarProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
pane: SftpPane;
|
||||
@@ -208,12 +249,17 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
const prevDisplayConnectionIdRef = useRef(pane.connection?.id);
|
||||
|
||||
useEffect(() => {
|
||||
const connectionChanged = pane.connection?.id !== prevDisplayConnectionIdRef.current;
|
||||
const previousConnectionId = prevDisplayConnectionIdRef.current;
|
||||
prevDisplayConnectionIdRef.current = pane.connection?.id;
|
||||
// Sync immediately on connection change; otherwise defer until loading completes
|
||||
if (connectionChanged || !pane.loading) {
|
||||
setDisplayPath(pane.connection?.currentPath ?? "");
|
||||
}
|
||||
setDisplayPath((previousDisplayPath) =>
|
||||
getNextSftpToolbarDisplayPath({
|
||||
previousDisplayPath,
|
||||
previousConnectionId,
|
||||
connectionId: pane.connection?.id,
|
||||
currentPath: pane.connection?.currentPath,
|
||||
loading: pane.loading,
|
||||
})
|
||||
);
|
||||
}, [pane.connection?.currentPath, pane.connection?.id, pane.loading]);
|
||||
|
||||
// Observe the overall toolbar width to decide whether to collapse action buttons
|
||||
@@ -248,6 +294,16 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
}
|
||||
}, [showFilterBar, setShowFilterBar, filterInputRef]);
|
||||
|
||||
const handleCopyCurrentPath = useCallback(async () => {
|
||||
await copySftpCurrentPathToClipboard({
|
||||
currentPath: displayPath,
|
||||
writeText: (text) => navigator.clipboard.writeText(text),
|
||||
onSuccess: (message) => toast.success(message, "SFTP"),
|
||||
onError: (message) => toast.error(message, "SFTP"),
|
||||
t,
|
||||
});
|
||||
}, [displayPath, t]);
|
||||
|
||||
const isRemote = !pane.connection?.isLocal;
|
||||
const viewModeToggleTarget = getSftpViewModeToggleTarget(viewMode);
|
||||
const viewModeToggleLabel = t(viewModeToggleTarget.labelKey);
|
||||
@@ -297,6 +353,21 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
aria-label={t("sftp.copyCurrentPath")}
|
||||
disabled={!displayPath}
|
||||
onClick={handleCopyCurrentPath}
|
||||
>
|
||||
<ClipboardCopy size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.copyCurrentPath")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -161,6 +161,47 @@ test("startSSH forwards custom ProxyCommand to the SSH bridge", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("startSSH forwards the saved sudo autofill password to the SSH bridge", async () => {
|
||||
let capturedOptions: Record<string, unknown> | null = null;
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async (options: Record<string, unknown>) => {
|
||||
capturedOptions = options;
|
||||
return "ssh-session";
|
||||
},
|
||||
startTelnetSession: async () => "telnet-session",
|
||||
startMoshSession: async () => "mosh-session",
|
||||
startLocalSession: async () => "local-session",
|
||||
startSerialSession: async () => "serial-session",
|
||||
execCommand: async () => ({}),
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
password: "login-secret",
|
||||
},
|
||||
terminalBackend,
|
||||
sudoAutofillPassword: "sudo-secret",
|
||||
});
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
|
||||
assert.equal(capturedOptions?.sudoAutofillPassword, "sudo-secret");
|
||||
});
|
||||
|
||||
test("startSSH enables sudo autofill only with the host saved password", async () => {
|
||||
let onData: ((data: string) => void) | null = null;
|
||||
const sent: string[] = [];
|
||||
@@ -255,7 +296,7 @@ test("startSSH does not use unsaved retry passwords for sudo autofill", async ()
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test("startSSH prefers latest sudo autofill password state over pending saved auth", async () => {
|
||||
test("startSSH uses pending saved auth for sudo autofill on the first saved connection", async () => {
|
||||
let onData: ((data: string) => void) | null = null;
|
||||
const sent: string[] = [];
|
||||
const terminalBackend = {
|
||||
@@ -296,14 +337,15 @@ test("startSSH prefers latest sudo autofill password state over pending saved au
|
||||
},
|
||||
},
|
||||
terminalBackend,
|
||||
sudoAutofillPasswordRef: { current: undefined },
|
||||
sudoAutofillPasswordRef: { current: "stale-secret" },
|
||||
});
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
ctx.sudoAutofillRef.current?.armForCommand("sudo whoami");
|
||||
onData?.("[sudo] password for alice: ");
|
||||
ctx.sudoAutofillRef.current?.confirmFill();
|
||||
|
||||
assert.deepEqual(sent, []);
|
||||
assert.deepEqual(sent, ["pending-secret\n"]);
|
||||
});
|
||||
|
||||
test("startSSH does not use merged group default passwords for sudo autofill", async () => {
|
||||
|
||||
@@ -53,13 +53,13 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
|
||||
const resolveSavedSudoAutofillPassword = (): string | undefined => {
|
||||
if (ctx.sudoAutofillPasswordRef) {
|
||||
return sanitizeCredentialValue(ctx.sudoAutofillPasswordRef.current);
|
||||
}
|
||||
const pendingAuth = ctx.pendingAuthRef.current;
|
||||
if (pendingAuth?.savedToHost && pendingAuth.password) {
|
||||
return sanitizeCredentialValue(pendingAuth.password);
|
||||
}
|
||||
if (ctx.sudoAutofillPasswordRef) {
|
||||
return sanitizeCredentialValue(ctx.sudoAutofillPasswordRef.current);
|
||||
}
|
||||
return sanitizeCredentialValue(ctx.sudoAutofillPassword);
|
||||
};
|
||||
|
||||
@@ -401,6 +401,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
sshDebugLogEnabled: ctx.sshDebugLogEnabled,
|
||||
identityFilePaths: attempt.password ? undefined : targetIdentityFilePaths,
|
||||
knownHosts: ctx.knownHosts,
|
||||
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
|
||||
// Ask the bridge to reuse the source tab's authenticated connection
|
||||
// (issue #1204). Only honored on the very first connect attempt; the
|
||||
// bridge silently falls back to a fresh connection if the source is
|
||||
@@ -764,6 +765,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
// Lets the stats companion verify the host key before sending a saved
|
||||
// password (#1198), so it never discloses it to an unvetted host.
|
||||
knownHosts: ctx.knownHosts,
|
||||
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
charset: ctx.host.charset,
|
||||
@@ -1002,6 +1004,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
knownHosts: ctx.knownHosts,
|
||||
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
agentForwarding: ctx.host.agentForwarding,
|
||||
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
charset: ctx.host.charset,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { fontStore } from "../../../application/state/fontStore";
|
||||
import { KeywordHighlighter } from "../keywordHighlight";
|
||||
import {
|
||||
XTERM_PERFORMANCE_CONFIG,
|
||||
resolveXTermScrollback,
|
||||
type XTermPlatform,
|
||||
resolveXTermPerformanceConfig,
|
||||
} from "../../../infrastructure/config/xtermPerformance";
|
||||
@@ -270,11 +271,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
const cursorStyle = settings?.cursorShape ?? "block";
|
||||
const cursorBlink = settings?.cursorBlink ?? true;
|
||||
// xterm.js treats scrollback=0 as "no scrollback buffer", which breaks mouse
|
||||
// wheel scrolling (events become arrow-key sequences). The UI uses 0 to mean
|
||||
// "no limit", so map it to a large value instead.
|
||||
const rawScrollback = settings?.scrollback ?? 10000;
|
||||
const scrollback = rawScrollback === 0 ? 999999 : rawScrollback;
|
||||
const scrollback = resolveXTermScrollback(rawScrollback);
|
||||
const drawBoldTextInBrightColors = settings?.drawBoldInBrightColors ?? true;
|
||||
const fontWeight = resolveHostTerminalFontWeight(ctx.host, settings?.fontWeight ?? 400);
|
||||
const fontWeightBold = settings?.fontWeightBold ?? 700;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
|
||||
import { useRef } from 'react';
|
||||
import { resolveFontWeightBold } from '../../lib/fontWeightAvailability';
|
||||
import { resolveXTermScrollback } from '../../infrastructure/config/xtermPerformance';
|
||||
import { shouldInterceptMouseTrackingContextMenu } from './runtime/middleClickBehavior';
|
||||
|
||||
type TerminalEffectsContext = Record<string, any>;
|
||||
@@ -465,7 +466,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
|
||||
if (terminalSettings) {
|
||||
applyUserCursorPreference(termRef.current, terminalSettings);
|
||||
termRef.current.options.scrollback = terminalSettings.scrollback === 0 ? 999999 : terminalSettings.scrollback;
|
||||
termRef.current.options.scrollback = resolveXTermScrollback(terminalSettings.scrollback);
|
||||
termRef.current.options.fontWeight = effectiveFontWeight as
|
||||
| 100
|
||||
| 200
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { LogView } from '../../application/state/logViewState';
|
||||
import { useWindowControls } from '../../application/state/useWindowControls';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { getEffectiveHostDistro } from '../../domain/host';
|
||||
import { resolveHostIconAppearance, resolveHostIconColorAppearance } from '../../domain/hostIcon';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Host, TerminalSession, Workspace } from '../../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from '../DistroAvatar';
|
||||
@@ -14,6 +15,7 @@ import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../../lib/t
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '../ui/context-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { SessionTabContextMenuContent } from './SessionTabContextMenuContent';
|
||||
import { renderHostIconGlyph } from '../hostIconRenderer';
|
||||
|
||||
// File extensions that render the code-file icon instead of the plain text icon.
|
||||
const CODE_EXTENSIONS_RE = /\.(js|jsx|ts|tsx|py|rb|go|rs|c|cpp|cs|java|php|sh|bash|zsh|fish|lua|r|scala|swift|kt|html|css|scss|less|json|yaml|yml|toml|xml|sql|graphql|gql|md|mdx|conf|ini|env|tf|hcl|dockerfile)$/i;
|
||||
@@ -78,14 +80,26 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
|
||||
);
|
||||
}
|
||||
|
||||
if (host) {
|
||||
const customAppearance = resolveHostIconAppearance(host);
|
||||
if (customAppearance) {
|
||||
return (
|
||||
<div className={cn(boxBase, "text-white")} style={{ backgroundColor: customAppearance.colorHex }}>
|
||||
{renderHostIconGlyph(customAppearance.iconId, iconSize)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Try distro logo with brand background color
|
||||
if (host) {
|
||||
const distro = getEffectiveHostDistro(host);
|
||||
const logo = DISTRO_LOGOS[distro];
|
||||
if (logo) {
|
||||
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
|
||||
const customColor = resolveHostIconColorAppearance(host);
|
||||
return (
|
||||
<div className={cn(boxBase, bg)}>
|
||||
<div className={cn(boxBase, !customColor && bg)} style={customColor ? { backgroundColor: customColor.colorHex } : undefined}>
|
||||
<img
|
||||
src={logo}
|
||||
alt={distro || host.os}
|
||||
|
||||
@@ -23,7 +23,9 @@ const makeHost = (overrides: Partial<Host> = {}): Host => ({
|
||||
hostname: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
authMethod: "password",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
createdAt: 1,
|
||||
protocol: "ssh",
|
||||
...overrides,
|
||||
@@ -136,6 +138,41 @@ test("migrateHostsFromLegacyLineTimestamps fills only missing host choices", ()
|
||||
]);
|
||||
});
|
||||
|
||||
test("sanitizeHost preserves valid custom host icon fields", () => {
|
||||
const sanitized = sanitizeHost(makeHost({
|
||||
iconMode: "custom",
|
||||
iconId: "database",
|
||||
iconColor: "blue",
|
||||
}));
|
||||
|
||||
assert.equal(sanitized.iconMode, "custom");
|
||||
assert.equal(sanitized.iconId, "database");
|
||||
assert.equal(sanitized.iconColor, "blue");
|
||||
});
|
||||
|
||||
test("sanitizeHost preserves automatic host icon color fields", () => {
|
||||
const sanitized = sanitizeHost(makeHost({
|
||||
iconMode: "auto",
|
||||
iconColor: "violet",
|
||||
}));
|
||||
|
||||
assert.equal(sanitized.iconMode, "auto");
|
||||
assert.equal(sanitized.iconId, undefined);
|
||||
assert.equal(sanitized.iconColor, "violet");
|
||||
});
|
||||
|
||||
test("sanitizeHost removes invalid custom host icon fields", () => {
|
||||
const sanitized = sanitizeHost(makeHost({
|
||||
iconMode: "custom",
|
||||
iconId: "bad",
|
||||
iconColor: "blue",
|
||||
} as unknown as Partial<Host>));
|
||||
|
||||
assert.equal(sanitized.iconMode, undefined);
|
||||
assert.equal(sanitized.iconId, undefined);
|
||||
assert.equal(sanitized.iconColor, undefined);
|
||||
});
|
||||
|
||||
test("preserves a concurrent terminal timestamp toggle when host details did not edit it", () => {
|
||||
const openedHost = makeHost({ showLineTimestamps: false });
|
||||
const latestHost = makeHost({ showLineTimestamps: true });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Host, TerminalSettings } from './models';
|
||||
import { sanitizeHostIconFields } from './hostIcon';
|
||||
import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts';
|
||||
|
||||
export type HostLabelRenameResult =
|
||||
@@ -326,6 +327,7 @@ export const sanitizeHost = (host: Host): Host => {
|
||||
: host.distroMode === 'auto'
|
||||
? 'auto'
|
||||
: undefined;
|
||||
const cleanHostIcon = sanitizeHostIconFields(host);
|
||||
const migrated = migrateDeprecatedFontOverride(host);
|
||||
const cleanNotes = host.notes?.trim() || undefined;
|
||||
return {
|
||||
@@ -334,6 +336,10 @@ export const sanitizeHost = (host: Host): Host => {
|
||||
distro: cleanDistro,
|
||||
distroMode: cleanDistroMode,
|
||||
manualDistro: cleanManualDistro || undefined,
|
||||
iconMode: undefined,
|
||||
iconId: undefined,
|
||||
iconColor: undefined,
|
||||
...cleanHostIcon,
|
||||
notes: cleanNotes,
|
||||
};
|
||||
};
|
||||
|
||||
78
domain/hostIcon.test.ts
Normal file
78
domain/hostIcon.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DEFAULT_HOST_ICON_COLOR,
|
||||
DEFAULT_HOST_ICON_ID,
|
||||
HOST_ICON_COLORS,
|
||||
clearHostIconAppearance,
|
||||
isHostIconColorId,
|
||||
isHostIconId,
|
||||
normalizeHostIconSelection,
|
||||
resolveHostIconAppearance,
|
||||
sanitizeHostIconFields,
|
||||
} from "./hostIcon.ts";
|
||||
|
||||
test("resolveHostIconAppearance returns null for automatic hosts", () => {
|
||||
assert.equal(resolveHostIconAppearance({}), null);
|
||||
assert.equal(resolveHostIconAppearance({ iconMode: "auto", iconId: "database", iconColor: "blue" }), null);
|
||||
});
|
||||
|
||||
test("automatic hosts may keep a custom palette color without a custom icon", () => {
|
||||
assert.deepEqual(sanitizeHostIconFields({ iconMode: "auto", iconColor: "violet" }), {
|
||||
iconMode: "auto",
|
||||
iconColor: "violet",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveHostIconAppearance returns validated custom icon and color", () => {
|
||||
assert.deepEqual(
|
||||
resolveHostIconAppearance({ iconMode: "custom", iconId: "database", iconColor: "blue" }),
|
||||
{ iconId: "database", colorId: "blue", colorHex: "#2563EB" },
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHostIconAppearance ignores invalid custom data", () => {
|
||||
assert.equal(
|
||||
resolveHostIconAppearance({ iconMode: "custom", iconId: "bad", iconColor: "blue" } as unknown as Parameters<typeof resolveHostIconAppearance>[0]),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveHostIconAppearance({ iconMode: "custom", iconId: "server", iconColor: "#123456" } as unknown as Parameters<typeof resolveHostIconAppearance>[0]),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeHostIconSelection creates a complete UI custom selection", () => {
|
||||
assert.deepEqual(normalizeHostIconSelection({ iconMode: "custom" }), {
|
||||
iconMode: "custom",
|
||||
iconId: DEFAULT_HOST_ICON_ID,
|
||||
iconColor: DEFAULT_HOST_ICON_COLOR,
|
||||
});
|
||||
});
|
||||
|
||||
test("sanitizeHostIconFields clears incomplete or invalid stored custom data", () => {
|
||||
assert.deepEqual(sanitizeHostIconFields({ iconMode: "custom" }), {});
|
||||
assert.deepEqual(
|
||||
sanitizeHostIconFields({ iconMode: "custom", iconId: "bad", iconColor: "blue" } as unknown as Parameters<typeof sanitizeHostIconFields>[0]),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test("clearHostIconAppearance removes custom icon fields", () => {
|
||||
assert.deepEqual(
|
||||
clearHostIconAppearance({ iconMode: "custom", iconId: "database", iconColor: "blue", label: "DB" }),
|
||||
{ label: "DB" },
|
||||
);
|
||||
});
|
||||
|
||||
test("host icon validators accept only curated IDs and color IDs", () => {
|
||||
assert.equal(isHostIconId("server"), true);
|
||||
assert.equal(isHostIconId("globe"), true);
|
||||
assert.equal(isHostIconId("server-cog"), true);
|
||||
assert.equal(isHostIconId("uploaded-file"), false);
|
||||
assert.equal(isHostIconColorId(HOST_ICON_COLORS[0].id), true);
|
||||
assert.equal(isHostIconColorId("violet"), true);
|
||||
assert.equal(HOST_ICON_COLORS.length, 16);
|
||||
assert.equal(isHostIconColorId("#2563EB"), false);
|
||||
});
|
||||
120
domain/hostIcon.ts
Normal file
120
domain/hostIcon.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { Host, HostIconColorId, HostIconId, HostIconMode } from "./models";
|
||||
|
||||
export const DEFAULT_HOST_ICON_ID: HostIconId = "server";
|
||||
export const DEFAULT_HOST_ICON_COLOR: HostIconColorId = "blue";
|
||||
|
||||
export const HOST_ICON_IDS = [
|
||||
"server",
|
||||
"terminal",
|
||||
"database",
|
||||
"cloud",
|
||||
"router",
|
||||
"shield",
|
||||
"code",
|
||||
"box",
|
||||
"globe",
|
||||
"cpu",
|
||||
"hard-drive",
|
||||
"network",
|
||||
"wifi",
|
||||
"lock",
|
||||
"key",
|
||||
"monitor",
|
||||
"container",
|
||||
"activity",
|
||||
"zap",
|
||||
"server-cog",
|
||||
] as const satisfies readonly HostIconId[];
|
||||
|
||||
export const HOST_ICON_COLORS = [
|
||||
{ id: "blue", hex: "#2563EB" },
|
||||
{ id: "green", hex: "#16A34A" },
|
||||
{ id: "red", hex: "#DC2626" },
|
||||
{ id: "amber", hex: "#B45309" },
|
||||
{ id: "purple", hex: "#9333EA" },
|
||||
{ id: "cyan", hex: "#0891B2" },
|
||||
{ id: "orange", hex: "#EA580C" },
|
||||
{ id: "slate", hex: "#475569" },
|
||||
{ id: "violet", hex: "#7C3AED" },
|
||||
{ id: "pink", hex: "#DB2777" },
|
||||
{ id: "rose", hex: "#E11D48" },
|
||||
{ id: "lime", hex: "#65A30D" },
|
||||
{ id: "teal", hex: "#0D9488" },
|
||||
{ id: "sky", hex: "#0284C7" },
|
||||
{ id: "indigo", hex: "#4F46E5" },
|
||||
{ id: "zinc", hex: "#52525B" },
|
||||
] as const satisfies readonly { id: HostIconColorId; hex: string }[];
|
||||
|
||||
export type HostIconAppearance = {
|
||||
iconId: HostIconId;
|
||||
colorId: HostIconColorId;
|
||||
colorHex: string;
|
||||
};
|
||||
|
||||
export type HostIconColorAppearance = {
|
||||
colorId: HostIconColorId;
|
||||
colorHex: string;
|
||||
};
|
||||
|
||||
export const isHostIconMode = (value: unknown): value is HostIconMode =>
|
||||
value === "auto" || value === "custom";
|
||||
|
||||
export const isHostIconId = (value: unknown): value is HostIconId =>
|
||||
typeof value === "string" && (HOST_ICON_IDS as readonly string[]).includes(value);
|
||||
|
||||
export const isHostIconColorId = (value: unknown): value is HostIconColorId =>
|
||||
typeof value === "string" && HOST_ICON_COLORS.some((color) => color.id === value);
|
||||
|
||||
const resolveColorHex = (colorId: HostIconColorId): string =>
|
||||
HOST_ICON_COLORS.find((color) => color.id === colorId)?.hex || HOST_ICON_COLORS[0].hex;
|
||||
|
||||
export const resolveHostIconColorAppearance = (
|
||||
host: Partial<Pick<Host, "iconColor">>,
|
||||
): HostIconColorAppearance | null => {
|
||||
if (!isHostIconColorId(host.iconColor)) return null;
|
||||
return {
|
||||
colorId: host.iconColor,
|
||||
colorHex: resolveColorHex(host.iconColor),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveHostIconAppearance = (
|
||||
host: Partial<Pick<Host, "iconMode" | "iconId" | "iconColor">>,
|
||||
): HostIconAppearance | null => {
|
||||
if (host.iconMode !== "custom") return null;
|
||||
if (!isHostIconId(host.iconId) || !isHostIconColorId(host.iconColor)) return null;
|
||||
return {
|
||||
iconId: host.iconId,
|
||||
colorId: host.iconColor,
|
||||
colorHex: resolveColorHex(host.iconColor),
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeHostIconSelection = <T extends Partial<Pick<Host, "iconMode" | "iconId" | "iconColor">>>(
|
||||
host: T,
|
||||
): Pick<Host, "iconMode" | "iconId" | "iconColor"> => {
|
||||
if (host.iconMode !== "custom") {
|
||||
const iconColor = isHostIconColorId(host.iconColor) ? host.iconColor : undefined;
|
||||
return iconColor ? { iconMode: "auto", iconColor } : {};
|
||||
}
|
||||
const iconId = isHostIconId(host.iconId) ? host.iconId : DEFAULT_HOST_ICON_ID;
|
||||
const iconColor = isHostIconColorId(host.iconColor) ? host.iconColor : DEFAULT_HOST_ICON_COLOR;
|
||||
return { iconMode: "custom", iconId, iconColor };
|
||||
};
|
||||
|
||||
export const sanitizeHostIconFields = <T extends Partial<Pick<Host, "iconMode" | "iconId" | "iconColor">>>(
|
||||
host: T,
|
||||
): Pick<Host, "iconMode" | "iconId" | "iconColor"> => {
|
||||
if (host.iconMode !== "custom") {
|
||||
return isHostIconColorId(host.iconColor) ? { iconMode: "auto", iconColor: host.iconColor } : {};
|
||||
}
|
||||
if (!isHostIconId(host.iconId) || !isHostIconColorId(host.iconColor)) return {};
|
||||
return { iconMode: "custom", iconId: host.iconId, iconColor: host.iconColor };
|
||||
};
|
||||
|
||||
export const clearHostIconAppearance = <T extends Record<string, unknown>>(
|
||||
host: T,
|
||||
): Omit<T, "iconMode" | "iconId" | "iconColor"> => {
|
||||
const { iconMode: _iconMode, iconId: _iconId, iconColor: _iconColor, ...rest } = host;
|
||||
return rest;
|
||||
};
|
||||
@@ -49,6 +49,45 @@ export interface EnvVar {
|
||||
|
||||
// Protocol type for connections
|
||||
export type HostProtocol = 'ssh' | 'telnet' | 'mosh' | 'et' | 'local' | 'serial';
|
||||
export type HostIconMode = 'auto' | 'custom';
|
||||
export type HostIconId =
|
||||
| 'server'
|
||||
| 'terminal'
|
||||
| 'database'
|
||||
| 'cloud'
|
||||
| 'router'
|
||||
| 'shield'
|
||||
| 'code'
|
||||
| 'box'
|
||||
| 'globe'
|
||||
| 'cpu'
|
||||
| 'hard-drive'
|
||||
| 'network'
|
||||
| 'wifi'
|
||||
| 'lock'
|
||||
| 'key'
|
||||
| 'monitor'
|
||||
| 'container'
|
||||
| 'activity'
|
||||
| 'zap'
|
||||
| 'server-cog';
|
||||
export type HostIconColorId =
|
||||
| 'blue'
|
||||
| 'green'
|
||||
| 'red'
|
||||
| 'amber'
|
||||
| 'purple'
|
||||
| 'cyan'
|
||||
| 'orange'
|
||||
| 'slate'
|
||||
| 'violet'
|
||||
| 'pink'
|
||||
| 'rose'
|
||||
| 'lime'
|
||||
| 'teal'
|
||||
| 'sky'
|
||||
| 'indigo'
|
||||
| 'zinc';
|
||||
|
||||
// Serial port configuration
|
||||
export type SerialParity = 'none' | 'even' | 'odd' | 'mark' | 'space';
|
||||
@@ -131,6 +170,9 @@ export interface Host {
|
||||
distro?: string; // detected distro id (e.g., ubuntu, debian)
|
||||
distroMode?: 'auto' | 'manual'; // whether distro icon comes from detection or manual override
|
||||
manualDistro?: string; // manually selected distro id when distroMode='manual'
|
||||
iconMode?: HostIconMode; // Optional host icon mode. Missing/auto preserves distro detection.
|
||||
iconId?: HostIconId; // Curated icon override used when iconMode='custom'
|
||||
iconColor?: HostIconColorId; // Palette color used with automatic or custom host icons
|
||||
// Multi-protocol support
|
||||
protocols?: ProtocolConfig[]; // Multiple protocol configurations
|
||||
telnetPort?: number; // Telnet-specific port (for quick access)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// Known Hosts - discovered from system SSH known_hosts file
|
||||
import type { HostIconColorId, HostIconId, HostIconMode } from './connection';
|
||||
|
||||
export interface KnownHost {
|
||||
id: string;
|
||||
hostname: string; // The host pattern from known_hosts
|
||||
@@ -46,6 +48,9 @@ export interface ConnectionLog {
|
||||
protocol: 'ssh' | 'telnet' | 'local' | 'mosh' | 'et' | 'serial';
|
||||
hostOs?: 'linux' | 'windows' | 'macos'; // Snapshot of the connected host OS for log icons
|
||||
hostDistro?: string; // Snapshot of the connected host distro/vendor icon id
|
||||
hostIconMode?: HostIconMode; // Snapshot of the host icon mode for log icons
|
||||
hostIconId?: HostIconId; // Snapshot of the built-in host icon id
|
||||
hostIconColor?: HostIconColorId; // Snapshot of the host icon color id
|
||||
startTime: number; // Connection start timestamp
|
||||
endTime?: number; // Connection end timestamp (undefined if still active)
|
||||
localUsername: string; // System username of the local user
|
||||
|
||||
@@ -9,7 +9,7 @@ export function isNetcattyAiHistoryCommand(command: string): boolean {
|
||||
}
|
||||
|
||||
const NETCATTY_MANAGED_STARTUP_COMMAND =
|
||||
/^printf '\\033\[H\\033\[2J\\033\[3J';\s*exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)/;
|
||||
/^(?:sh\s+-c\s+.*printf .*\\033\[H\\033\[2J\\033\[3J.*_nc_docker_err=.*\bdocker\s+inspect\b|printf '\\033\[H\\033\[2J\\033\[3J';\s*(?:_nc_docker_err=.*\bdocker\s+inspect\b|exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)))/;
|
||||
|
||||
/** True when a shell history line came from a Netcatty-managed terminal launch. */
|
||||
export function isNetcattyManagedStartupHistoryCommand(command: string): boolean {
|
||||
|
||||
30
domain/systemManager/dockerShell.test.ts
Normal file
30
domain/systemManager/dockerShell.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { buildDockerExecShellCommand, buildDockerLogsCommand } from './dockerShell.ts';
|
||||
|
||||
test('buildDockerExecShellCommand probes plain Docker before sudo fallback', () => {
|
||||
const command = buildDockerExecShellCommand('587abcdef123');
|
||||
|
||||
assert.match(command, /^sh -c /);
|
||||
assert.match(command, /printf .*\\033\[H\\033\[2J\\033\[3J/);
|
||||
assert.match(command, /docker inspect 587abcdef123/);
|
||||
assert.match(command, /exec docker exec -it 587abcdef123/);
|
||||
assert.match(command, /exec sudo docker exec -it 587abcdef123/);
|
||||
assert.match(command, /permission\\ denied.*docker.sock.*docker.sock.*permission\\ denied/);
|
||||
assert.doesNotMatch(command, /sudo -S/);
|
||||
assert.equal(command.includes('\n'), false);
|
||||
});
|
||||
|
||||
test('buildDockerLogsCommand probes plain Docker before sudo fallback', () => {
|
||||
const command = buildDockerLogsCommand('587abcdef123');
|
||||
|
||||
assert.match(command, /^sh -c /);
|
||||
assert.match(command, /printf .*\\033\[H\\033\[2J\\033\[3J/);
|
||||
assert.match(command, /docker inspect 587abcdef123/);
|
||||
assert.match(command, /exec docker logs -f --tail 200 587abcdef123/);
|
||||
assert.match(command, /exec sudo docker logs -f --tail 200 587abcdef123/);
|
||||
assert.match(command, /permission\\ denied.*docker.sock.*docker.sock.*permission\\ denied/);
|
||||
assert.doesNotMatch(command, /sudo -S/);
|
||||
assert.equal(command.includes('\n'), false);
|
||||
});
|
||||
@@ -5,15 +5,48 @@ export function sanitizeDockerContainerId(id: string): string {
|
||||
|
||||
const CLEAR_STARTUP_OUTPUT = "printf '\\033[H\\033[2J\\033[3J';";
|
||||
|
||||
function shQuote(value: string): string {
|
||||
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function buildDockerCommandWithSudoFallback(containerId: string, dockerArgs: string): string {
|
||||
const plainCommand = `docker ${dockerArgs}`;
|
||||
const sudoCommand = `sudo ${plainCommand}`;
|
||||
const script = [
|
||||
CLEAR_STARTUP_OUTPUT,
|
||||
`_nc_docker_err=$(docker inspect ${containerId} 2>&1 >/dev/null);`,
|
||||
'_nc_docker_status=$?;',
|
||||
`if [ "$_nc_docker_status" -eq 0 ]; then exec ${plainCommand}; fi;`,
|
||||
'_nc_docker_lc=$(printf \'%s\' "$_nc_docker_err" | tr \'[:upper:]\' \'[:lower:]\');',
|
||||
'case "$_nc_docker_lc" in',
|
||||
[
|
||||
'*permission\\ denied*docker\\ daemon*',
|
||||
'*docker\\ daemon*permission\\ denied*',
|
||||
'*permission\\ denied*docker.sock*',
|
||||
'*docker.sock*permission\\ denied*',
|
||||
'*permission\\ denied*/var/run/docker.sock*',
|
||||
'*/var/run/docker.sock*permission\\ denied*',
|
||||
'*permission\\ denied*connect\\ to\\ the\\ docker\\ daemon*',
|
||||
'*connect\\ to\\ the\\ docker\\ daemon*permission\\ denied*',
|
||||
].join('|') + `) exec ${sudoCommand} ;;`,
|
||||
'*) printf \'%s\\n\' "$_nc_docker_err" >&2; exit "$_nc_docker_status" ;;',
|
||||
'esac',
|
||||
].join(' ');
|
||||
return `sh -c ${shQuote(script)}`;
|
||||
}
|
||||
|
||||
/** Interactive shell into a container — prefer bash, fall back to sh. */
|
||||
export function buildDockerExecShellCommand(containerId: string): string {
|
||||
const safeId = sanitizeDockerContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return `${CLEAR_STARTUP_OUTPUT} exec docker exec -it ${safeId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`;
|
||||
return buildDockerCommandWithSudoFallback(
|
||||
safeId,
|
||||
`exec -it ${safeId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildDockerLogsCommand(containerId: string): string {
|
||||
const safeId = sanitizeDockerContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return `${CLEAR_STARTUP_OUTPUT} exec docker logs -f --tail 200 ${safeId}`;
|
||||
return buildDockerCommandWithSudoFallback(safeId, `logs -f --tail 200 ${safeId}`);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ function createStartSessionApi(ctx) {
|
||||
hostname: options.host || options.hostname || '',
|
||||
username: options.username || '',
|
||||
label: options.label || '',
|
||||
systemManagerSudoPassword: typeof options.sudoAutofillPassword === 'string' && options.sudoAutofillPassword.length > 0
|
||||
? options.sudoAutofillPassword
|
||||
: undefined,
|
||||
lastIdlePrompt: '',
|
||||
lastIdlePromptAt: 0,
|
||||
_promptTrackTail: '',
|
||||
|
||||
@@ -19,6 +19,37 @@ function sanitizeImageRef(ref) {
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function isSuccessfulCommandResult(result) {
|
||||
return result?.success && (result.code === 0 || result.code === null || result.code === undefined);
|
||||
}
|
||||
|
||||
function dockerCommandError(result, fallback) {
|
||||
return (result?.stderr || result?.error || "").trim() || fallback;
|
||||
}
|
||||
|
||||
function isDockerSocketPermissionError(result) {
|
||||
const text = `${result?.stderr || ""}\n${result?.stdout || ""}\n${result?.error || ""}`.toLowerCase();
|
||||
if (!text.includes("permission denied")) return false;
|
||||
return text.includes("docker daemon")
|
||||
|| text.includes("docker.sock")
|
||||
|| text.includes("/var/run/docker.sock")
|
||||
|| text.includes("connect to the docker daemon");
|
||||
}
|
||||
|
||||
function getSessionSudoPassword(session) {
|
||||
return typeof session?.systemManagerSudoPassword === "string" && session.systemManagerSudoPassword.length > 0
|
||||
? session.systemManagerSudoPassword
|
||||
: null;
|
||||
}
|
||||
|
||||
function buildDockerCommand(args) {
|
||||
return `docker ${args}`.trim();
|
||||
}
|
||||
|
||||
function buildSudoDockerCommand(args) {
|
||||
return `sudo -S -p '' ${buildDockerCommand(args)}`;
|
||||
}
|
||||
|
||||
function parseDockerContainers(stdout) {
|
||||
const containers = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
@@ -132,15 +163,35 @@ function summarizeContainerInspect(info) {
|
||||
};
|
||||
}
|
||||
|
||||
function createDockerOpsApi({ execOnSession }) {
|
||||
function createDockerOpsApi({ execOnSession, getSession }) {
|
||||
async function runDocker(event, sessionId, args, timeoutMs = 15000) {
|
||||
const cmd = `docker ${args}`;
|
||||
const cmd = buildDockerCommand(args);
|
||||
const result = await execOnSession(event, sessionId, cmd, timeoutMs);
|
||||
if (isSuccessfulCommandResult(result)) return result;
|
||||
|
||||
const sudoPassword = getSessionSudoPassword(getSession?.(sessionId));
|
||||
|
||||
if (sudoPassword && isDockerSocketPermissionError(result)) {
|
||||
const sudoResult = await execOnSession(
|
||||
event,
|
||||
sessionId,
|
||||
buildSudoDockerCommand(args),
|
||||
timeoutMs,
|
||||
{ stdin: `${sudoPassword}\n` },
|
||||
);
|
||||
if (isSuccessfulCommandResult(sudoResult)) return sudoResult;
|
||||
return {
|
||||
success: false,
|
||||
error: dockerCommandError(sudoResult, `sudo docker exited with code ${sudoResult?.code}`),
|
||||
stderr: sudoResult?.stderr,
|
||||
};
|
||||
}
|
||||
|
||||
if (!result.success) return result;
|
||||
if (result.code !== 0 && result.code !== null && result.code !== undefined) {
|
||||
return {
|
||||
success: false,
|
||||
error: (result.stderr || "").trim() || `docker exited with code ${result.code}`,
|
||||
error: dockerCommandError(result, `docker exited with code ${result.code}`),
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
@@ -148,23 +199,13 @@ function createDockerOpsApi({ execOnSession }) {
|
||||
}
|
||||
|
||||
async function listContainers(event, sessionId) {
|
||||
const result = await execOnSession(
|
||||
event,
|
||||
sessionId,
|
||||
"docker ps -a --format '{{json .}}'",
|
||||
12000,
|
||||
);
|
||||
const result = await runDocker(event, sessionId, "ps -a --format '{{json .}}'", 12000);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
return { success: true, containers: parseDockerContainers(result.stdout) };
|
||||
}
|
||||
|
||||
async function listImages(event, sessionId) {
|
||||
const result = await execOnSession(
|
||||
event,
|
||||
sessionId,
|
||||
"docker images --format '{{json .}}'",
|
||||
12000,
|
||||
);
|
||||
const result = await runDocker(event, sessionId, "images --format '{{json .}}'", 12000);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
return { success: true, images: parseDockerImages(result.stdout) };
|
||||
}
|
||||
@@ -174,10 +215,10 @@ function createDockerOpsApi({ execOnSession }) {
|
||||
if (!sessionId) return { success: false, error: "Missing sessionId" };
|
||||
const ids = Array.isArray(payload?.ids) ? payload.ids.filter(Boolean) : [];
|
||||
const idArg = ids.map((id) => sanitizeDockerId(id)).filter(Boolean).join(" ");
|
||||
const result = await execOnSession(
|
||||
const result = await runDocker(
|
||||
event,
|
||||
sessionId,
|
||||
`docker stats --no-stream --format '{{json .}}' ${idArg}`.trim(),
|
||||
`stats --no-stream --format '{{json .}}' ${idArg}`.trim(),
|
||||
15000,
|
||||
);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
@@ -188,7 +229,7 @@ function createDockerOpsApi({ execOnSession }) {
|
||||
const { sessionId, containerId } = payload || {};
|
||||
if (!sessionId || !containerId) return { success: false, error: "Missing params" };
|
||||
const safeId = sanitizeDockerId(containerId);
|
||||
const result = await execOnSession(event, sessionId, `docker inspect ${safeId}`, 10000);
|
||||
const result = await runDocker(event, sessionId, `inspect ${safeId}`, 10000);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout || "[]");
|
||||
@@ -203,7 +244,7 @@ function createDockerOpsApi({ execOnSession }) {
|
||||
const { sessionId, imageId } = payload || {};
|
||||
if (!sessionId || !imageId) return { success: false, error: "Missing params" };
|
||||
const safeId = sanitizeDockerId(imageId);
|
||||
const result = await execOnSession(event, sessionId, `docker image inspect ${safeId}`, 10000);
|
||||
const result = await runDocker(event, sessionId, `image inspect ${safeId}`, 10000);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout || "[]");
|
||||
|
||||
186
electron/bridges/systemManager/dockerOps.test.cjs
Normal file
186
electron/bridges/systemManager/dockerOps.test.cjs
Normal file
@@ -0,0 +1,186 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { createDockerOpsApi } = require("./dockerOps.cjs");
|
||||
|
||||
test("listContainers uses plain docker first even when a saved session password exists", async () => {
|
||||
const calls = [];
|
||||
const dockerOps = createDockerOpsApi({
|
||||
getSession: () => ({ systemManagerSudoPassword: "host-secret" }),
|
||||
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
|
||||
calls.push({ sessionId, command, timeoutMs, execOptions });
|
||||
return {
|
||||
success: true,
|
||||
stdout: '{"ID":"abc123","Names":"web","Image":"nginx","State":"running"}\n',
|
||||
stderr: "",
|
||||
code: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dockerOps.listContainers(null, "s1");
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.containers.length, 1);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(
|
||||
calls[0].command,
|
||||
"docker ps -a --format '{{json .}}'",
|
||||
);
|
||||
assert.equal(calls[0].execOptions, undefined);
|
||||
});
|
||||
|
||||
test("listContainers falls back to sudo when plain docker hits socket permission denial", async () => {
|
||||
const calls = [];
|
||||
const dockerOps = createDockerOpsApi({
|
||||
getSession: () => ({ systemManagerSudoPassword: "host-secret" }),
|
||||
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
|
||||
calls.push({ sessionId, command, timeoutMs, execOptions });
|
||||
if (calls.length === 1) {
|
||||
return {
|
||||
success: true,
|
||||
stdout: "",
|
||||
stderr: "permission denied while trying to connect to the Docker daemon socket",
|
||||
code: 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
stdout: '{"ID":"abc123","Names":"web","Image":"nginx","State":"running"}\n',
|
||||
stderr: "",
|
||||
code: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dockerOps.listContainers(null, "s1");
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.containers.length, 1);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0].command, "docker ps -a --format '{{json .}}'");
|
||||
assert.equal(calls[0].execOptions, undefined);
|
||||
assert.equal(
|
||||
calls[1].command,
|
||||
"sudo -S -p '' docker ps -a --format '{{json .}}'",
|
||||
);
|
||||
assert.deepEqual(calls[1].execOptions, { stdin: "host-secret\n" });
|
||||
});
|
||||
|
||||
test("listContainers uses plain docker when no saved password exists", async () => {
|
||||
const calls = [];
|
||||
const dockerOps = createDockerOpsApi({
|
||||
getSession: () => ({}),
|
||||
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
|
||||
calls.push({ sessionId, command, timeoutMs, execOptions });
|
||||
return {
|
||||
success: true,
|
||||
stdout: "",
|
||||
stderr: "Got permission denied while trying to connect to the Docker daemon socket",
|
||||
code: 1,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dockerOps.listContainers(null, "s1");
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error, /permission denied/i);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test("listContainers does not retry with transport auth passwords that were not saved for sudo autofill", async () => {
|
||||
const calls = [];
|
||||
const dockerOps = createDockerOpsApi({
|
||||
getSession: () => ({
|
||||
moshStatsAuth: { password: "interactive-mosh-password" },
|
||||
etStatsAuth: { password: "interactive-et-password" },
|
||||
}),
|
||||
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
|
||||
calls.push({ sessionId, command, timeoutMs, execOptions });
|
||||
return {
|
||||
success: true,
|
||||
stdout: "",
|
||||
stderr: "permission denied while trying to connect to the Docker daemon socket",
|
||||
code: 1,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dockerOps.listContainers(null, "s1");
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error, /permission denied/i);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test("listContainers retries with explicit sudo autofill password on mosh or et sessions", async () => {
|
||||
const calls = [];
|
||||
const dockerOps = createDockerOpsApi({
|
||||
getSession: () => ({
|
||||
systemManagerSudoPassword: "saved-secret",
|
||||
moshStatsAuth: { password: "transport-secret" },
|
||||
}),
|
||||
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
|
||||
calls.push({ sessionId, command, timeoutMs, execOptions });
|
||||
if (calls.length === 1) {
|
||||
return {
|
||||
success: true,
|
||||
stdout: "",
|
||||
stderr: "dial unix /var/run/docker.sock: connect: permission denied",
|
||||
code: 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
stdout: '{"ID":"abc123","Names":"web","Image":"nginx","State":"running"}\n',
|
||||
stderr: "",
|
||||
code: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dockerOps.listContainers(null, "s1");
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(
|
||||
calls[1].command,
|
||||
"sudo -S -p '' docker ps -a --format '{{json .}}'",
|
||||
);
|
||||
assert.deepEqual(calls[1].execOptions, { stdin: "saved-secret\n" });
|
||||
});
|
||||
|
||||
test("docker image actions retry with sudo and send saved passwords through stdin", async () => {
|
||||
const calls = [];
|
||||
const dockerOps = createDockerOpsApi({
|
||||
getSession: () => ({ systemManagerSudoPassword: "pa'ss" }),
|
||||
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
|
||||
calls.push({ sessionId, command, timeoutMs, execOptions });
|
||||
if (calls.length === 1) {
|
||||
return {
|
||||
success: true,
|
||||
stdout: "",
|
||||
stderr: "dial unix /var/run/docker.sock: connect: permission denied",
|
||||
code: 1,
|
||||
};
|
||||
}
|
||||
return { success: true, stdout: "deleted\n", stderr: "", code: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dockerOps.imageAction(null, {
|
||||
sessionId: "s1",
|
||||
action: "rm",
|
||||
imageId: "sha256:abc123",
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(
|
||||
calls[1].command,
|
||||
"sudo -S -p '' docker rmi sha256abc123",
|
||||
);
|
||||
assert.deepEqual(calls[1].execOptions, { stdin: "pa'ss\n" });
|
||||
});
|
||||
@@ -78,7 +78,7 @@ function createExecOnSessionApi(ctx) {
|
||||
return conn;
|
||||
}
|
||||
|
||||
function execOnConnection(conn, command, timeoutMs) {
|
||||
function execOnConnection(conn, command, timeoutMs, execOptions = {}) {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let activeStream = null;
|
||||
@@ -106,6 +106,10 @@ function createExecOnSessionApi(ctx) {
|
||||
if (stream.stderr) {
|
||||
stream.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
|
||||
}
|
||||
if (typeof execOptions.stdin === "string") {
|
||||
stream.write(execOptions.stdin);
|
||||
stream.end();
|
||||
}
|
||||
stream.on("close", (code) => {
|
||||
settle({ success: true, stdout, stderr, code: code ?? 0 });
|
||||
});
|
||||
@@ -116,7 +120,7 @@ function createExecOnSessionApi(ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
async function execOnSshSession(session, sessionId, command, timeoutMs, event, allowCompanionRetry = true) {
|
||||
async function execOnSshSession(session, sessionId, command, timeoutMs, event, execOptions = {}, allowCompanionRetry = true) {
|
||||
if (session?.type === "et") {
|
||||
if (typeof execOnEtSession !== "function") {
|
||||
return { success: false, error: "ET command executor unavailable" };
|
||||
@@ -124,6 +128,7 @@ function createExecOnSessionApi(ctx) {
|
||||
return execOnEtSession(session, command, timeoutMs, {
|
||||
requireTrustedHost: true,
|
||||
knownHosts: session.etStatsAuth?.knownHosts,
|
||||
stdin: execOptions.stdin,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,7 +140,7 @@ function createExecOnSessionApi(ctx) {
|
||||
return { success: false, error: "Session not found or not connected" };
|
||||
}
|
||||
|
||||
const result = await execOnConnection(conn, command, timeoutMs);
|
||||
const result = await execOnConnection(conn, command, timeoutMs, execOptions);
|
||||
if (
|
||||
allowCompanionRetry
|
||||
&& !result.success
|
||||
@@ -143,18 +148,18 @@ function createExecOnSessionApi(ctx) {
|
||||
&& isTransportExecError(result.error)
|
||||
) {
|
||||
session.moshStatsConn = null;
|
||||
return execOnSshSession(session, sessionId, command, timeoutMs, event, false);
|
||||
return execOnSshSession(session, sessionId, command, timeoutMs, event, execOptions, false);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function execOnLocalMachine(command, timeoutMs) {
|
||||
async function execOnLocalMachine(command, timeoutMs, execOptions = {}) {
|
||||
const { execFile } = require("node:child_process");
|
||||
const platform = process.platform;
|
||||
|
||||
if (platform === "win32") {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
const child = execFile(
|
||||
"powershell.exe",
|
||||
["-NoProfile", "-NonInteractive", "-Command", command],
|
||||
{ timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 },
|
||||
@@ -166,11 +171,14 @@ function createExecOnSessionApi(ctx) {
|
||||
resolve({ success: true, stdout: String(stdout || ""), stderr: String(stderr || ""), code: err?.code ?? 0 });
|
||||
},
|
||||
);
|
||||
if (typeof execOptions.stdin === "string") {
|
||||
child.stdin?.end(execOptions.stdin);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
const child = execFile(
|
||||
"sh",
|
||||
["-c", command],
|
||||
{ timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 },
|
||||
@@ -182,10 +190,13 @@ function createExecOnSessionApi(ctx) {
|
||||
resolve({ success: true, stdout: String(stdout || ""), stderr: String(stderr || ""), code: err?.code ?? 0 });
|
||||
},
|
||||
);
|
||||
if (typeof execOptions.stdin === "string") {
|
||||
child.stdin?.end(execOptions.stdin);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function execOnSessionInner(event, sessionId, command, timeoutMs = 8000) {
|
||||
async function execOnSessionInner(event, sessionId, command, timeoutMs = 8000, execOptions = {}) {
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
execQueues.delete(sessionId);
|
||||
@@ -193,18 +204,18 @@ function createExecOnSessionApi(ctx) {
|
||||
}
|
||||
|
||||
if (session.protocol === "local" || session.type === "local") {
|
||||
return execOnLocalMachine(command, timeoutMs);
|
||||
return execOnLocalMachine(command, timeoutMs, execOptions);
|
||||
}
|
||||
|
||||
if (session.conn || session.type === "mosh" || session.type === "et") {
|
||||
return execOnSshSession(session, sessionId, command, timeoutMs, event);
|
||||
return execOnSshSession(session, sessionId, command, timeoutMs, event, execOptions);
|
||||
}
|
||||
|
||||
return { success: false, error: "Session not supported for system management" };
|
||||
}
|
||||
|
||||
async function execOnSession(event, sessionId, command, timeoutMs = 8000) {
|
||||
return enqueueExec(sessionId, () => execOnSessionInner(event, sessionId, command, timeoutMs));
|
||||
async function execOnSession(event, sessionId, command, timeoutMs = 8000, execOptions = {}) {
|
||||
return enqueueExec(sessionId, () => execOnSessionInner(event, sessionId, command, timeoutMs, execOptions));
|
||||
}
|
||||
|
||||
function isLocalSession(sessionId) {
|
||||
|
||||
38
electron/bridges/systemManager/execOnSession.stdin.test.cjs
Normal file
38
electron/bridges/systemManager/execOnSession.stdin.test.cjs
Normal file
@@ -0,0 +1,38 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { createExecOnSessionApi } = require("./execOnSession.cjs");
|
||||
|
||||
test("execOnSession closes ssh exec stdin after writing provided input", async () => {
|
||||
const writes = [];
|
||||
let ended = false;
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
stream.write = (data) => {
|
||||
writes.push(data);
|
||||
return true;
|
||||
};
|
||||
stream.end = () => {
|
||||
ended = true;
|
||||
};
|
||||
|
||||
const conn = {
|
||||
exec(_command, callback) {
|
||||
callback(null, stream);
|
||||
process.nextTick(() => stream.emit("close", 0));
|
||||
},
|
||||
};
|
||||
const execApi = createExecOnSessionApi({
|
||||
sessions: { get: () => ({ conn, type: "ssh" }) },
|
||||
});
|
||||
|
||||
const result = await execApi.execOnSession(null, "s1", "sudo -S -p '' docker ps", 1000, {
|
||||
stdin: "secret\n",
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.deepEqual(writes, ["secret\n"]);
|
||||
assert.equal(ended, true);
|
||||
});
|
||||
@@ -9,7 +9,7 @@ const CAPABILITY_SCRIPT_POSIX = [
|
||||
"'",
|
||||
'printf "%s\\n" "__NC_OS__=$(uname -s)"; ',
|
||||
'command -v tmux >/dev/null 2>&1 && printf "%s\\n" __NC_TMUX__=1; ',
|
||||
'(docker info >/dev/null 2>&1 || (command -v docker >/dev/null 2>&1 && [ -r /var/run/docker.sock ])) && printf "%s\\n" __NC_DOCKER__=1',
|
||||
'command -v docker >/dev/null 2>&1 && printf "%s\\n" __NC_DOCKER__=1',
|
||||
"'",
|
||||
].join("");
|
||||
|
||||
@@ -111,10 +111,10 @@ function createSystemManagerBridge(deps) {
|
||||
ensureMoshStatsConnection,
|
||||
});
|
||||
|
||||
const { execOnSession, execOnLocalMachine, isLocalSession } = execApi;
|
||||
const { execOnSession, execOnLocalMachine, isLocalSession, getSession } = execApi;
|
||||
|
||||
const tmuxOps = createTmuxOpsApi({ execOnSession });
|
||||
const dockerOps = createDockerOpsApi({ execOnSession });
|
||||
const dockerOps = createDockerOpsApi({ execOnSession, getSession });
|
||||
|
||||
async function probeCapabilities(event, payload) {
|
||||
const sessionId = payload?.sessionId;
|
||||
@@ -136,7 +136,7 @@ function createSystemManagerBridge(deps) {
|
||||
8000,
|
||||
);
|
||||
if (!result.success) {
|
||||
const fallback = await execOnLocalMachine("uname -s; command -v tmux; (docker info >/dev/null 2>&1 || (command -v docker >/dev/null 2>&1 && [ -r /var/run/docker.sock ])) && echo docker_ok", 8000);
|
||||
const fallback = await execOnLocalMachine("uname -s; command -v tmux; command -v docker >/dev/null 2>&1 && echo docker_ok", 8000);
|
||||
if (!fallback.success) return { success: false, error: fallback.error || "Probe failed" };
|
||||
const text = fallback.stdout || "";
|
||||
return {
|
||||
|
||||
@@ -46,3 +46,24 @@ test("listProcesses uses a ps format that works on CentOS 7 procps", async () =>
|
||||
assert.equal(result.processes[0].pid, 1);
|
||||
assert.equal(result.processes[0].command, "/usr/lib/systemd/systemd --switched-root --system --deserialize 21");
|
||||
});
|
||||
|
||||
test("probeCapabilities reports Docker when docker is installed even if plain docker access is denied", async () => {
|
||||
const conn = {
|
||||
exec(command, callback) {
|
||||
assert.match(command, /command -v docker/);
|
||||
assert.doesNotMatch(command, /docker info/);
|
||||
assert.doesNotMatch(command, /docker\.sock/);
|
||||
callback(null, createFakeExecStream("__NC_OS__=Linux\n__NC_DOCKER__=1\n"));
|
||||
},
|
||||
};
|
||||
const sessions = new Map([["s1", { conn, type: "ssh" }]]);
|
||||
const bridge = createSystemManagerBridge({
|
||||
getSessions: () => sessions,
|
||||
process,
|
||||
});
|
||||
|
||||
const result = await bridge.probeCapabilities(null, { sessionId: "s1" });
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.capabilities.hasDocker, true);
|
||||
});
|
||||
|
||||
@@ -654,7 +654,7 @@ main();
|
||||
args.push(session.sshUserHost, command);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(sshCmd, args, {
|
||||
const child = execFile(sshCmd, args, {
|
||||
env: { ...process.env, ...session.sshEnv },
|
||||
timeout: timeoutMs,
|
||||
encoding: "utf8",
|
||||
@@ -672,6 +672,9 @@ main();
|
||||
resolve({ success: true, stdout: stdout || "", stderr: stderr || "", code: 0 });
|
||||
}
|
||||
});
|
||||
if (typeof execOpts.stdin === "string") {
|
||||
child.stdin?.end(execOpts.stdin);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -791,6 +794,9 @@ main();
|
||||
knownHosts: options.knownHosts,
|
||||
hasJumpHost: Array.isArray(options.jumpHosts) && options.jumpHosts.length > 0,
|
||||
},
|
||||
systemManagerSudoPassword: typeof options.sudoAutofillPassword === "string" && options.sudoAutofillPassword.length > 0
|
||||
? options.sudoAutofillPassword
|
||||
: undefined,
|
||||
flushPendingData: null,
|
||||
lastIdlePrompt: "",
|
||||
lastIdlePromptAt: 0,
|
||||
|
||||
@@ -572,6 +572,9 @@ function createMoshSessionApi(ctx) {
|
||||
// does not depend on this.
|
||||
knownHosts: options.knownHosts,
|
||||
};
|
||||
session.systemManagerSudoPassword = typeof options.sudoAutofillPassword === "string" && options.sudoAutofillPassword.length > 0
|
||||
? options.sudoAutofillPassword
|
||||
: undefined;
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
const decoder = new StringDecoder("utf8");
|
||||
|
||||
2
global.d.ts
vendored
2
global.d.ts
vendored
@@ -119,6 +119,8 @@ declare global {
|
||||
algorithmOverrides?: import("./domain/models").HostAlgorithmOverrides;
|
||||
// Use sudo for SFTP server
|
||||
sudo?: boolean;
|
||||
// Saved host password used by background system tools when they need sudo.
|
||||
sudoAutofillPassword?: string;
|
||||
// Session log configuration for real-time streaming
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string; timestampsEnabled?: boolean };
|
||||
// SSH connection diagnostics. Does not capture terminal output.
|
||||
|
||||
17
infrastructure/config/xtermPerformance.test.ts
Normal file
17
infrastructure/config/xtermPerformance.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
XTERM_UNLIMITED_SCROLLBACK_CAP,
|
||||
resolveXTermScrollback,
|
||||
} from './xtermPerformance';
|
||||
|
||||
test('resolveXTermScrollback maps the unlimited sentinel to a 50000 row cap', () => {
|
||||
assert.equal(XTERM_UNLIMITED_SCROLLBACK_CAP, 50000);
|
||||
assert.equal(resolveXTermScrollback(0), 50000);
|
||||
});
|
||||
|
||||
test('resolveXTermScrollback preserves explicit positive scrollback values', () => {
|
||||
assert.equal(resolveXTermScrollback(10000), 10000);
|
||||
assert.equal(resolveXTermScrollback(50000), 50000);
|
||||
});
|
||||
@@ -8,6 +8,14 @@
|
||||
* - Memory pressure handling
|
||||
*/
|
||||
|
||||
export const XTERM_UNLIMITED_SCROLLBACK_CAP = 50000;
|
||||
|
||||
export function resolveXTermScrollback(scrollback: number): number {
|
||||
// xterm.js treats 0 as "no scrollback". Keep the app's 0 sentinel useful
|
||||
// without asking xterm to resize/reflow nearly one million buffer rows.
|
||||
return scrollback === 0 ? XTERM_UNLIMITED_SCROLLBACK_CAP : scrollback;
|
||||
}
|
||||
|
||||
export const XTERM_PERFORMANCE_CONFIG = {
|
||||
// Memory and Scrollback Settings
|
||||
scrollback: {
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/ai/*.test.ts components/ai/*.test.ts components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts infrastructure/config/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts"
|
||||
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/i18n/locales/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/ai/*.test.ts components/ai/*.test.ts components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts infrastructure/config/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.58",
|
||||
|
||||
2
types/global/netcatty-bridge-session.d.ts
vendored
2
types/global/netcatty-bridge-session.d.ts
vendored
@@ -29,6 +29,7 @@ declare global {
|
||||
moshServerPath?: string;
|
||||
moshClientPath?: string;
|
||||
agentForwarding?: boolean;
|
||||
sudoAutofillPassword?: string;
|
||||
// Algorithm settings, forwarded so the host-info stats companion SSH
|
||||
// connection (issue #1198) negotiates the same KEX / cipher / host-key
|
||||
// set the interactive session would.
|
||||
@@ -63,6 +64,7 @@ declare global {
|
||||
knownHosts?: import("../../domain/models").KnownHost[];
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
agentForwarding?: boolean;
|
||||
sudoAutofillPassword?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
|
||||
Reference in New Issue
Block a user