Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecadc1fc2d | ||
|
|
79ccf47655 | ||
|
|
6ef0a4ad6b | ||
|
|
88142d2a92 | ||
|
|
f5c3302329 | ||
|
|
bb02f8e162 | ||
|
|
d57dd664a2 | ||
|
|
74ec6678bb |
6
App.tsx
6
App.tsx
@@ -216,6 +216,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionRenameValue,
|
||||
setSessionRenameValue,
|
||||
startSessionRename,
|
||||
renameSessionInline,
|
||||
submitSessionRename,
|
||||
resetSessionRename,
|
||||
workspaceRenameTarget,
|
||||
@@ -235,6 +236,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
removeSessionFromWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
@@ -728,7 +730,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
);
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, isQuickSwitcherOpen, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings, confirmIfBusyLocalTerminal]);
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, isQuickSwitcherOpen, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, toggleWorkspaceViewMode, settings, confirmIfBusyLocalTerminal]);
|
||||
|
||||
const handleWindowCommandCloseRequest = useCallback(async () => {
|
||||
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
|
||||
@@ -988,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, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
<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 }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -440,7 +440,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
|
||||
}
|
||||
|
||||
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, workspaces } = getCtx();
|
||||
{
|
||||
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces + editor tabs.
|
||||
// Hiding the SFTP tab must also remove it from keyboard cycling so nextTab
|
||||
@@ -539,6 +539,40 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
|
||||
break;
|
||||
}
|
||||
case 'closeSession': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
|
||||
if (closeTabInFlightRef.current) break;
|
||||
|
||||
const session = sessions.find((s) => s.id === currentId) ?? null;
|
||||
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
|
||||
|
||||
closeTabInFlightRef.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
// If active tab is a workspace, close the focused session (pane)
|
||||
if (workspace) {
|
||||
// Validate focusedSessionId is still valid — it can become stale
|
||||
// if the previously focused session was already closed
|
||||
const aliveIds = collectSessionIds(workspace.root);
|
||||
const focusedId = aliveIds.includes(workspace.focusedSessionId)
|
||||
? workspace.focusedSessionId
|
||||
: aliveIds[0];
|
||||
if (focusedId) {
|
||||
const ok = await confirmIfBusyLocalTerminal([focusedId]);
|
||||
if (ok) closeSession(focusedId);
|
||||
}
|
||||
} else if (session) {
|
||||
// Standalone session tab — close the session
|
||||
const ok = await confirmIfBusyLocalTerminal([session.id]);
|
||||
if (ok) closeSession(session.id);
|
||||
}
|
||||
} finally {
|
||||
closeTabInFlightRef.current = false;
|
||||
}
|
||||
})();
|
||||
break;
|
||||
}
|
||||
case 'newTab':
|
||||
case 'openLocal':
|
||||
// Add connection log for local terminal
|
||||
@@ -644,6 +678,15 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'togglePaneZoom': {
|
||||
// Toggle workspace between split and focus (zoom) mode
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeWs) {
|
||||
toggleWorkspaceViewMode(activeWs.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'moveFocus': {
|
||||
// Debounce to prevent double-triggering when focus switches between terminals
|
||||
const now = Date.now();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -42,13 +42,13 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
|
||||
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
|
||||
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, 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,
|
||||
@@ -134,6 +134,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onStartSessionDrag={setDraggingSessionId}
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
editorTabs={editorTabs}
|
||||
@@ -152,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}
|
||||
/>
|
||||
@@ -281,6 +287,9 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
|
||||
onReorderWorkspaceSessions={reorderWorkspaceSessions}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||
onSplitSession={splitSessionWithCurrentShell}
|
||||
onConnectToHost={handleConnectToHost}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
@@ -307,6 +316,9 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
toggleSidePanelRef={toggleSidePanelRef}
|
||||
onStartSessionRename={startSessionRename}
|
||||
onSubmitSessionRename={submitSessionRename}
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,10 +6,40 @@ import {
|
||||
isHostTreeWorkTabSurface,
|
||||
isRootPageTabId,
|
||||
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(
|
||||
@@ -18,6 +48,29 @@ test('work tab order keeps custom positions and appends new tabs', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('work tab order removes duplicate ids before rendering', () => {
|
||||
assert.deepEqual(
|
||||
buildOrderedWorkTabIds(
|
||||
['session-2', 'session-1', 'session-2', 'session-1'],
|
||||
['session-1', 'session-2', 'session-3', 'session-3'],
|
||||
),
|
||||
['session-2', 'session-1', 'session-3'],
|
||||
);
|
||||
});
|
||||
|
||||
test('work tab order reorders with newly materialized tabs', () => {
|
||||
assert.deepEqual(
|
||||
reorderWorkTabIds(
|
||||
['session-1', 'session-2', 'session-3'],
|
||||
['session-1', 'session-2', 'session-3'],
|
||||
'session-1',
|
||||
'session-3',
|
||||
'after',
|
||||
),
|
||||
['session-2', 'session-3', 'session-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('root pages are not work tab surfaces', () => {
|
||||
assert.equal(isRootPageTabId('vault'), true);
|
||||
assert.equal(isRootPageTabId('sftp'), true);
|
||||
@@ -80,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,20 @@ 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>();
|
||||
const uniqueIds: string[] = [];
|
||||
for (const tabId of tabIds) {
|
||||
if (!tabId || seen.has(tabId)) continue;
|
||||
seen.add(tabId);
|
||||
uniqueIds.push(tabId);
|
||||
}
|
||||
return uniqueIds;
|
||||
}
|
||||
|
||||
export function isRootPageTabId(activeTabId: string): boolean {
|
||||
return activeTabId === 'vault' || activeTabId === 'sftp';
|
||||
@@ -13,13 +25,42 @@ export function buildOrderedWorkTabIds(
|
||||
tabOrder: readonly string[],
|
||||
allTabIds: readonly string[],
|
||||
): string[] {
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
const orderedIds = tabOrder.filter((id) => allTabIdSet.has(id));
|
||||
const uniqueAllTabIds = uniqueTabIds(allTabIds);
|
||||
const allTabIdSet = new Set(uniqueAllTabIds);
|
||||
const orderedIds = uniqueTabIds(tabOrder.filter((id) => allTabIdSet.has(id)));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter((id) => !orderedIdSet.has(id));
|
||||
const newIds = uniqueAllTabIds.filter((id) => !orderedIdSet.has(id));
|
||||
return [...orderedIds, ...newIds];
|
||||
}
|
||||
|
||||
export function reorderWorkTabIds(
|
||||
tabOrder: readonly string[],
|
||||
allTabIds: readonly string[],
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: 'before' | 'after' = 'before',
|
||||
): string[] {
|
||||
if (draggedId === targetId) return buildOrderedWorkTabIds(tabOrder, allTabIds);
|
||||
|
||||
const currentOrder = buildOrderedWorkTabIds(tabOrder, allTabIds);
|
||||
const draggedIndex = currentOrder.indexOf(draggedId);
|
||||
const targetIndex = currentOrder.indexOf(targetId);
|
||||
if (draggedIndex === -1 || targetIndex === -1) return [...tabOrder];
|
||||
|
||||
currentOrder.splice(draggedIndex, 1);
|
||||
|
||||
let nextTargetIndex = targetIndex;
|
||||
if (draggedIndex < targetIndex) {
|
||||
nextTargetIndex -= 1;
|
||||
}
|
||||
if (position === 'after') {
|
||||
nextTargetIndex += 1;
|
||||
}
|
||||
|
||||
currentOrder.splice(nextTargetIndex, 0, draggedId);
|
||||
return currentOrder;
|
||||
}
|
||||
|
||||
export function isHostTreeWorkTabSurface({
|
||||
enabled,
|
||||
activeTabId,
|
||||
@@ -85,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',
|
||||
|
||||
@@ -51,6 +51,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.composeBar.snippetClickHint': 'Click to insert · Shift+Click to send',
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.detach': 'Detach to standalone tab',
|
||||
'terminal.toolbar.encoding': 'Terminal Encoding',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
@@ -111,6 +112,9 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.menu.splitVertical': 'Split Vertical',
|
||||
'terminal.menu.clearBuffer': 'Clear Buffer',
|
||||
'terminal.menu.closeTerminal': 'Close terminal',
|
||||
'terminal.menu.rename': 'Rename',
|
||||
'terminal.menu.detach': 'Detach from workspace',
|
||||
'terminal.menu.detachSession': 'Detach {name}',
|
||||
'terminal.ymodem.selectFile': 'Select file to send',
|
||||
'terminal.ymodem.allFiles': 'All files',
|
||||
'terminal.ymodem.started': 'YMODEM sending {fileName}',
|
||||
|
||||
@@ -485,6 +485,7 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
|
||||
'hostDetails.distro.option.openeuler': 'openEuler',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': 'Cisco',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -72,6 +72,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.composeBar.snippetClickHint': 'Клик — вставить · Shift+клик — отправить',
|
||||
'terminal.toolbar.focus': 'Фокус',
|
||||
'terminal.toolbar.focusMode': 'Режим фокуса',
|
||||
'terminal.toolbar.detach': 'Открепить в отдельную вкладку',
|
||||
'terminal.toolbar.encoding': 'Кодировка терминала',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
@@ -132,6 +133,9 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.menu.splitVertical': 'Разделить по вертикали',
|
||||
'terminal.menu.clearBuffer': 'Очистить буфер',
|
||||
'terminal.menu.closeTerminal': 'Закрыть терминал',
|
||||
'terminal.menu.rename': 'Переименовать',
|
||||
'terminal.menu.detach': 'Открепить из рабочей области',
|
||||
'terminal.menu.detachSession': 'Открепить {name}',
|
||||
'terminal.ymodem.selectFile': 'Выберите файл для отправки',
|
||||
'terminal.ymodem.allFiles': 'Все файлы',
|
||||
'terminal.ymodem.started': 'YMODEM отправляет {fileName}',
|
||||
|
||||
@@ -520,6 +520,7 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
|
||||
'hostDetails.distro.option.openeuler': 'openEuler',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': 'Cisco',
|
||||
|
||||
55
application/i18n/locales/settingsLocales.test.ts
Normal file
55
application/i18n/locales/settingsLocales.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.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`);
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,9 @@ import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 粘贴 sudo 密码',
|
||||
'terminal.menu.rename': '重命名',
|
||||
'terminal.toolbar.detach': '移出到独立标签',
|
||||
'terminal.menu.detach': '从工作区移出',
|
||||
'terminal.toolbar.timestampsEnable': '显示时间戳',
|
||||
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
@@ -187,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': '行间距',
|
||||
@@ -325,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': '启用自动补全',
|
||||
@@ -359,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': '快速切换',
|
||||
@@ -386,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/命令代理',
|
||||
|
||||
@@ -66,6 +66,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': '阿里云 Linux',
|
||||
'hostDetails.distro.option.openeuler': 'openEuler',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': '思科',
|
||||
@@ -244,6 +245,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.composeBar.snippetClickHint': '单击插入 · Shift+单击直接发送',
|
||||
'terminal.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.toolbar.detach': '移出到独立标签',
|
||||
'terminal.toolbar.encoding': '终端编码',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
@@ -304,6 +306,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.menu.splitVertical': '垂直分屏',
|
||||
'terminal.menu.clearBuffer': '清空缓冲区',
|
||||
'terminal.menu.closeTerminal': '关闭终端',
|
||||
'terminal.menu.rename': '重命名',
|
||||
'terminal.menu.detach': '从工作区移出',
|
||||
'terminal.menu.detachSession': '移出 {name}',
|
||||
'terminal.ymodem.selectFile': '选择要发送的文件',
|
||||
'terminal.ymodem.allFiles': '所有文件',
|
||||
'terminal.ymodem.started': '正在通过 YMODEM 发送 {fileName}',
|
||||
|
||||
@@ -1,31 +1,45 @@
|
||||
import type { SessionCapabilities } from '../../domain/systemManager/types';
|
||||
|
||||
/** Internal entry: capabilities plus computed expiry timestamp. */
|
||||
interface StoreEntry {
|
||||
capabilities: SessionCapabilities;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const capabilitiesBySessionId = new Map<string, SessionCapabilities>();
|
||||
const capabilitiesBySessionId = new Map<string, StoreEntry>();
|
||||
const listenersBySessionId = new Map<string, Set<Listener>>();
|
||||
|
||||
function isExpired(entry: StoreEntry): boolean {
|
||||
return Date.now() > entry.expiresAt;
|
||||
}
|
||||
|
||||
function notifySession(sessionId: string) {
|
||||
listenersBySessionId.get(sessionId)?.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export const sessionCapabilitiesStore = {
|
||||
get(sessionId: string): SessionCapabilities | undefined {
|
||||
return capabilitiesBySessionId.get(sessionId);
|
||||
const entry = capabilitiesBySessionId.get(sessionId);
|
||||
if (!entry) return undefined;
|
||||
if (isExpired(entry)) {
|
||||
capabilitiesBySessionId.delete(sessionId);
|
||||
notifySession(sessionId);
|
||||
return undefined;
|
||||
}
|
||||
return entry.capabilities;
|
||||
},
|
||||
|
||||
set(sessionId: string, capabilities: SessionCapabilities) {
|
||||
const prev = capabilitiesBySessionId.get(sessionId);
|
||||
if (
|
||||
prev
|
||||
&& prev.targetOs === capabilities.targetOs
|
||||
&& prev.hasTmux === capabilities.hasTmux
|
||||
&& prev.hasDocker === capabilities.hasDocker
|
||||
&& prev.probedAt === capabilities.probedAt
|
||||
) {
|
||||
return;
|
||||
}
|
||||
capabilitiesBySessionId.set(sessionId, capabilities);
|
||||
set(sessionId: string, capabilities: SessionCapabilities, ttlMs: number) {
|
||||
const entry: StoreEntry = {
|
||||
capabilities: {
|
||||
...capabilities,
|
||||
probedAt: Date.now(),
|
||||
},
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
};
|
||||
capabilitiesBySessionId.set(sessionId, entry);
|
||||
notifySession(sessionId);
|
||||
},
|
||||
|
||||
|
||||
123
application/state/sessionWorkspaceDetach.test.ts
Normal file
123
application/state/sessionWorkspaceDetach.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||
import {
|
||||
closeSessionWorkspaceLayoutState,
|
||||
detachSessionFromWorkspaceState,
|
||||
replaceDissolvedWorkspaceTabOrder,
|
||||
} from "./sessionWorkspaceDetach";
|
||||
|
||||
const session = (id: string, workspaceId = "ws-1"): TerminalSession => ({
|
||||
id,
|
||||
hostId: id,
|
||||
hostLabel: id,
|
||||
status: "connected",
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const workspace = (sessionIds: string[]): Workspace => ({
|
||||
id: "ws-1",
|
||||
title: "Workspace",
|
||||
focusedSessionId: sessionIds[0],
|
||||
focusSessionOrder: sessionIds,
|
||||
root: sessionIds.length === 1
|
||||
? { id: "pane-1", type: "pane", sessionId: sessionIds[0] }
|
||||
: {
|
||||
id: "split-1",
|
||||
type: "split",
|
||||
direction: "vertical",
|
||||
children: sessionIds.map((sessionId, index) => ({
|
||||
id: `pane-${index + 1}`,
|
||||
type: "pane" as const,
|
||||
sessionId,
|
||||
})),
|
||||
sizes: sessionIds.map(() => 1),
|
||||
},
|
||||
});
|
||||
|
||||
test("detach dissolves the original workspace when one session remains", () => {
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: [session("s1"), session("s2")],
|
||||
workspaces: [workspace(["s1", "s2"])],
|
||||
sessionId: "s1",
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.activeTabId, "s1");
|
||||
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||||
["s1", undefined],
|
||||
["s2", undefined],
|
||||
]);
|
||||
assert.equal(result.workspaces.length, 0);
|
||||
assert.equal(result.dissolvedWorkspaceId, "ws-1");
|
||||
assert.deepEqual(result.replacementTabIds, ["s1", "s2"]);
|
||||
});
|
||||
|
||||
test("detach preserves the other sessions in a multi-pane workspace", () => {
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: [session("s1"), session("s2"), session("s3")],
|
||||
workspaces: [workspace(["s1", "s2", "s3"])],
|
||||
sessionId: "s2",
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||||
["s1", "ws-1"],
|
||||
["s2", undefined],
|
||||
["s3", "ws-1"],
|
||||
]);
|
||||
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s3"]);
|
||||
assert.equal(result.workspaces[0].focusedSessionId, "s1");
|
||||
assert.deepEqual(
|
||||
result.workspaces[0].root.type === "split"
|
||||
? result.workspaces[0].root.children.map((child) => child.type === "pane" ? child.sessionId : null)
|
||||
: [],
|
||||
["s1", "s3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("dissolved workspace replacement preserves its tab position", () => {
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
|
||||
["log-1", "s1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("dissolved workspace replacement removes duplicate replacement ids", () => {
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(["s1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
|
||||
["s1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("dissolved workspace replacement is idempotent", () => {
|
||||
const once = replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]);
|
||||
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(once, "ws-1", ["s1", "s2"]),
|
||||
once,
|
||||
);
|
||||
});
|
||||
|
||||
test("single remaining session preserves dissolved workspace tab position", () => {
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s2"]),
|
||||
["log-1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("closing a workspace session dissolves the workspace when one terminal remains", () => {
|
||||
const result = closeSessionWorkspaceLayoutState([workspace(["s1", "s2"])], "ws-1", "s1");
|
||||
|
||||
assert.equal(result.dissolvedWorkspaceId, "ws-1");
|
||||
assert.equal(result.lastRemainingSessionId, "s2");
|
||||
assert.deepEqual(result.workspaces, []);
|
||||
assert.deepEqual(
|
||||
replaceDissolvedWorkspaceTabOrder(
|
||||
["log-1", result.dissolvedWorkspaceId!, "session-3"],
|
||||
result.dissolvedWorkspaceId,
|
||||
result.lastRemainingSessionId ? [result.lastRemainingSessionId] : undefined,
|
||||
),
|
||||
["log-1", "s2", "session-3"],
|
||||
);
|
||||
});
|
||||
182
application/state/sessionWorkspaceDetach.ts
Normal file
182
application/state/sessionWorkspaceDetach.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||
import { collectSessionIds, pruneWorkspaceNode } from "../../domain/workspace";
|
||||
|
||||
export type DetachSessionFromWorkspaceStateResult = {
|
||||
changed: boolean;
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
activeTabId?: string;
|
||||
dissolvedWorkspaceId?: string;
|
||||
replacementTabIds?: string[];
|
||||
};
|
||||
|
||||
export type CloseSessionWorkspaceLayoutResult = {
|
||||
workspaces: Workspace[];
|
||||
removedWorkspaceId?: string;
|
||||
dissolvedWorkspaceId?: string;
|
||||
lastRemainingSessionId?: string;
|
||||
};
|
||||
|
||||
type DetachSessionFromWorkspaceStateOptions = {
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export function replaceDissolvedWorkspaceTabOrder(
|
||||
tabOrder: readonly string[],
|
||||
workspaceId: string | undefined,
|
||||
replacementTabIds: readonly string[] | undefined,
|
||||
): string[] {
|
||||
if (!workspaceId || !replacementTabIds?.length) return [...tabOrder];
|
||||
|
||||
const uniqueReplacementIds = replacementTabIds.filter((tabId, index, list) => (
|
||||
tabId && list.indexOf(tabId) === index
|
||||
));
|
||||
if (uniqueReplacementIds.length === 0) return [...tabOrder];
|
||||
|
||||
if (!tabOrder.includes(workspaceId)) {
|
||||
const hasAllReplacementIds = uniqueReplacementIds.every((tabId) => tabOrder.includes(tabId));
|
||||
return hasAllReplacementIds ? [...tabOrder] : [
|
||||
...tabOrder,
|
||||
...uniqueReplacementIds.filter((tabId) => !tabOrder.includes(tabId)),
|
||||
];
|
||||
}
|
||||
|
||||
const replacementIdSet = new Set(uniqueReplacementIds);
|
||||
let inserted = false;
|
||||
const nextOrder: string[] = [];
|
||||
|
||||
for (const tabId of tabOrder) {
|
||||
if (tabId === workspaceId) {
|
||||
if (!inserted) {
|
||||
nextOrder.push(...uniqueReplacementIds);
|
||||
inserted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!replacementIdSet.has(tabId)) {
|
||||
nextOrder.push(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
return nextOrder;
|
||||
}
|
||||
|
||||
export function closeSessionWorkspaceLayoutState(
|
||||
workspaces: readonly Workspace[],
|
||||
workspaceId: string | undefined,
|
||||
sessionId: string,
|
||||
): CloseSessionWorkspaceLayoutResult {
|
||||
if (!workspaceId) return { workspaces: [...workspaces] };
|
||||
|
||||
let removedWorkspaceId: string | undefined;
|
||||
let dissolvedWorkspaceId: string | undefined;
|
||||
let lastRemainingSessionId: string | undefined;
|
||||
const nextWorkspaces = workspaces
|
||||
.map((workspace) => {
|
||||
if (workspace.id !== workspaceId) return workspace;
|
||||
const prunedRoot = pruneWorkspaceNode(workspace.root, sessionId);
|
||||
if (!prunedRoot) {
|
||||
removedWorkspaceId = workspace.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
const remainingSessionIds = collectSessionIds(prunedRoot);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
dissolvedWorkspaceId = workspace.id;
|
||||
lastRemainingSessionId = remainingSessionIds[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...workspace, root: prunedRoot };
|
||||
})
|
||||
.filter((workspace): workspace is Workspace => Boolean(workspace));
|
||||
|
||||
return {
|
||||
workspaces: nextWorkspaces,
|
||||
removedWorkspaceId,
|
||||
dissolvedWorkspaceId,
|
||||
lastRemainingSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function detachSessionFromWorkspaceState({
|
||||
sessions,
|
||||
workspaces,
|
||||
sessionId,
|
||||
}: DetachSessionFromWorkspaceStateOptions): DetachSessionFromWorkspaceStateResult {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (!session?.workspaceId) {
|
||||
return { changed: false, sessions, workspaces };
|
||||
}
|
||||
|
||||
const workspaceId = session.workspaceId;
|
||||
const targetWorkspace = workspaces.find((workspace) => workspace.id === workspaceId);
|
||||
if (!targetWorkspace) {
|
||||
return { changed: false, sessions, workspaces };
|
||||
}
|
||||
|
||||
const prunedRoot = pruneWorkspaceNode(targetWorkspace.root, sessionId);
|
||||
let nextSessions = sessions.map((candidate) => (
|
||||
candidate.id === sessionId ? { ...candidate, workspaceId: undefined } : candidate
|
||||
));
|
||||
|
||||
if (!prunedRoot) {
|
||||
return {
|
||||
changed: true,
|
||||
sessions: nextSessions,
|
||||
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
|
||||
activeTabId: sessionId,
|
||||
dissolvedWorkspaceId: workspaceId,
|
||||
replacementTabIds: [sessionId],
|
||||
};
|
||||
}
|
||||
|
||||
const remainingSessionIds = collectSessionIds(prunedRoot);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
nextSessions = nextSessions.map((candidate) => (
|
||||
candidate.id === remainingSessionIds[0] ? { ...candidate, workspaceId: undefined } : candidate
|
||||
));
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
sessions: nextSessions,
|
||||
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
|
||||
activeTabId: sessionId,
|
||||
dissolvedWorkspaceId: workspaceId,
|
||||
replacementTabIds: [sessionId, ...remainingSessionIds],
|
||||
};
|
||||
}
|
||||
|
||||
const nextFocusedSessionId = remainingSessionIds.includes(targetWorkspace.focusedSessionId)
|
||||
? targetWorkspace.focusedSessionId
|
||||
: remainingSessionIds[0];
|
||||
const nextFocusSessionOrder = (targetWorkspace.focusSessionOrder ?? [])
|
||||
.filter((candidateId, index, list) => (
|
||||
candidateId !== sessionId &&
|
||||
remainingSessionIds.includes(candidateId) &&
|
||||
list.indexOf(candidateId) === index
|
||||
));
|
||||
for (const remainingSessionId of remainingSessionIds) {
|
||||
if (!nextFocusSessionOrder.includes(remainingSessionId)) {
|
||||
nextFocusSessionOrder.push(remainingSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
sessions: nextSessions,
|
||||
workspaces: workspaces.map((workspace) => (
|
||||
workspace.id === workspaceId
|
||||
? {
|
||||
...workspace,
|
||||
root: prunedRoot,
|
||||
focusedSessionId: nextFocusedSessionId,
|
||||
focusSessionOrder: nextFocusSessionOrder,
|
||||
}
|
||||
: workspace
|
||||
)),
|
||||
activeTabId: sessionId,
|
||||
};
|
||||
}
|
||||
62
application/state/terminalDragData.ts
Normal file
62
application/state/terminalDragData.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export const WORKSPACE_SESSION_DRAG_TYPE = 'application/x-netcatty-workspace-session';
|
||||
|
||||
type DataTransferLike = {
|
||||
types: DOMStringList | readonly string[];
|
||||
getData: (format: string) => string;
|
||||
};
|
||||
|
||||
export function dataTransferHasType(dataTransfer: Pick<DataTransferLike, 'types'>, type: string): boolean {
|
||||
return Array.prototype.includes.call(dataTransfer.types, type);
|
||||
}
|
||||
|
||||
export function hasWorkspaceSessionDrag(dataTransfer: Pick<DataTransferLike, 'types'>): boolean {
|
||||
return dataTransferHasType(dataTransfer, WORKSPACE_SESSION_DRAG_TYPE);
|
||||
}
|
||||
|
||||
export function getWorkspaceSessionDragId(dataTransfer: DataTransferLike): string {
|
||||
return dataTransfer.getData(WORKSPACE_SESSION_DRAG_TYPE) || dataTransfer.getData('session-id');
|
||||
}
|
||||
|
||||
export function isPointInsideRect(
|
||||
point: { clientX: number; clientY: number },
|
||||
rect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>,
|
||||
): boolean {
|
||||
return point.clientX >= rect.left
|
||||
&& point.clientX <= rect.right
|
||||
&& point.clientY >= rect.top
|
||||
&& point.clientY <= rect.bottom;
|
||||
}
|
||||
|
||||
export type TopTabInsertionTarget = {
|
||||
tabId: string;
|
||||
position: 'before' | 'after';
|
||||
};
|
||||
|
||||
export function getTopTabInsertionTarget(
|
||||
point: { clientX: number; clientY: number },
|
||||
topTabsRoot: HTMLElement | null,
|
||||
): TopTabInsertionTarget | null {
|
||||
if (!topTabsRoot || !isPointInsideRect(point, topTabsRoot.getBoundingClientRect())) return null;
|
||||
|
||||
const tabs = Array.from(topTabsRoot.querySelectorAll<HTMLElement>('[data-tab-id]'))
|
||||
.filter((tab) => tab.dataset.tabType !== 'root');
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
for (const tab of tabs) {
|
||||
const rect = tab.getBoundingClientRect();
|
||||
const midpoint = rect.left + rect.width / 2;
|
||||
const tabId = tab.dataset.tabId;
|
||||
if (!tabId) continue;
|
||||
if (point.clientX <= midpoint) {
|
||||
return { tabId, position: 'before' };
|
||||
}
|
||||
if (point.clientX <= rect.right) {
|
||||
return { tabId, position: 'after' };
|
||||
}
|
||||
}
|
||||
|
||||
const lastTab = tabs[tabs.length - 1];
|
||||
const lastTabId = lastTab?.dataset.tabId;
|
||||
return lastTabId ? { tabId: lastTabId, position: 'after' } : null;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export const getAppLevelActions = (): Set<string> => {
|
||||
'nextTab',
|
||||
'prevTab',
|
||||
'closeTab',
|
||||
'closeSession',
|
||||
'newTab',
|
||||
'openHosts',
|
||||
'openSftp',
|
||||
@@ -35,6 +36,7 @@ export const getAppLevelActions = (): Set<string> => {
|
||||
'splitVertical',
|
||||
'moveFocus',
|
||||
'broadcast',
|
||||
'togglePaneZoom',
|
||||
'openLocal',
|
||||
'openSettings',
|
||||
]);
|
||||
|
||||
@@ -17,8 +17,13 @@ SplitHint,
|
||||
updateWorkspaceSplitSizes,
|
||||
} from '../../domain/workspace';
|
||||
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
|
||||
import { buildOrderedWorkTabIds } from '../app/workTabSurface';
|
||||
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import {
|
||||
closeSessionWorkspaceLayoutState,
|
||||
detachSessionFromWorkspaceState,
|
||||
replaceDissolvedWorkspaceTabOrder,
|
||||
} from './sessionWorkspaceDetach';
|
||||
import {
|
||||
createCopiedTerminalSessionClone,
|
||||
createSplitTerminalSessionClone,
|
||||
@@ -122,33 +127,12 @@ export const useSessionState = () => {
|
||||
const wsId = targetSession?.workspaceId;
|
||||
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
let removedWorkspaceId: string | null = null;
|
||||
let nextWorkspaces = prevWorkspaces;
|
||||
let dissolvedWorkspaceId: string | null = null;
|
||||
let lastRemainingSessionId: string | null = null;
|
||||
|
||||
if (wsId) {
|
||||
nextWorkspaces = prevWorkspaces
|
||||
.map(ws => {
|
||||
if (ws.id !== wsId) return ws;
|
||||
const pruned = pruneWorkspaceNode(ws.root, sessionId);
|
||||
if (!pruned) {
|
||||
removedWorkspaceId = ws.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if only 1 session remains - dissolve workspace
|
||||
const remainingSessionIds = collectSessionIds(pruned);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
dissolvedWorkspaceId = ws.id;
|
||||
lastRemainingSessionId = remainingSessionIds[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...ws, root: pruned };
|
||||
})
|
||||
.filter((ws): ws is Workspace => Boolean(ws));
|
||||
}
|
||||
const {
|
||||
workspaces: nextWorkspaces,
|
||||
removedWorkspaceId,
|
||||
dissolvedWorkspaceId,
|
||||
lastRemainingSessionId,
|
||||
} = closeSessionWorkspaceLayoutState(prevWorkspaces, wsId, sessionId);
|
||||
|
||||
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
|
||||
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
|
||||
@@ -162,6 +146,14 @@ export const useSessionState = () => {
|
||||
return 'vault';
|
||||
};
|
||||
|
||||
if (dissolvedWorkspaceId && lastRemainingSessionId) {
|
||||
setTabOrder(prevTabOrder => replaceDissolvedWorkspaceTabOrder(
|
||||
prevTabOrder,
|
||||
dissolvedWorkspaceId,
|
||||
[lastRemainingSessionId],
|
||||
));
|
||||
}
|
||||
|
||||
if (dissolvedWorkspaceId && currentActiveTabId === dissolvedWorkspaceId) {
|
||||
setActiveTabId(getFallback());
|
||||
} else if (currentActiveTabId === sessionId) {
|
||||
@@ -205,20 +197,39 @@ export const useSessionState = () => {
|
||||
const target = prevSessions.find(s => s.id === sessionId);
|
||||
if (target) {
|
||||
setSessionRenameTarget(target);
|
||||
setSessionRenameValue(target.hostLabel);
|
||||
setSessionRenameValue(target.customName || target.hostLabel);
|
||||
}
|
||||
return prevSessions;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const submitSessionRename = useCallback(() => {
|
||||
const renameSessionInline = useCallback((sessionId: string, name: string) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setSessions(prev => prev.map(s => (
|
||||
s.id === sessionId ? { ...s, customName: trimmed, hostLabel: trimmed } : s
|
||||
)));
|
||||
}, []);
|
||||
|
||||
const submitSessionRename = useCallback((sessionId?: string, name?: string) => {
|
||||
if (sessionId !== undefined && name !== undefined) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setSessions(prev => prev.map(s => (
|
||||
s.id === sessionId ? { ...s, customName: trimmed, hostLabel: trimmed } : s
|
||||
)));
|
||||
return;
|
||||
}
|
||||
|
||||
setSessionRenameValue(prevValue => {
|
||||
const name = prevValue.trim();
|
||||
if (!name) return prevValue;
|
||||
const trimmed = prevValue.trim();
|
||||
if (!trimmed) return prevValue;
|
||||
|
||||
setSessionRenameTarget(prevTarget => {
|
||||
if (!prevTarget) return prevTarget;
|
||||
setSessions(prev => prev.map(s => s.id === prevTarget.id ? { ...s, hostLabel: name } : s));
|
||||
setSessions(prev => prev.map(s => (
|
||||
s.id === prevTarget.id ? { ...s, customName: trimmed, hostLabel: trimmed } : s
|
||||
)));
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -888,6 +899,50 @@ export const useSessionState = () => {
|
||||
[getOrderedWorkTabs],
|
||||
);
|
||||
|
||||
const removeSessionFromWorkspace = useCallback((
|
||||
sessionId: string,
|
||||
tabInsertionTarget?: {
|
||||
tabId: string;
|
||||
position: 'before' | 'after';
|
||||
additionalTabIds?: readonly string[];
|
||||
},
|
||||
) => {
|
||||
setSessions(prevSessions => {
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: prevSessions,
|
||||
workspaces: workspacesRef.current,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (!result.changed) return prevSessions;
|
||||
setWorkspaces(result.workspaces);
|
||||
setTabOrder(prevTabOrder => {
|
||||
const replacedOrder = replaceDissolvedWorkspaceTabOrder(
|
||||
prevTabOrder,
|
||||
result.dissolvedWorkspaceId,
|
||||
result.replacementTabIds,
|
||||
);
|
||||
if (!tabInsertionTarget) return replacedOrder;
|
||||
|
||||
const allTabIds = [
|
||||
...result.sessions.filter(s => !s.workspaceId).map(s => s.id),
|
||||
...result.workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
...(tabInsertionTarget.additionalTabIds ?? []),
|
||||
];
|
||||
return reorderWorkTabIds(
|
||||
replacedOrder,
|
||||
allTabIds,
|
||||
sessionId,
|
||||
tabInsertionTarget.tabId,
|
||||
tabInsertionTarget.position,
|
||||
);
|
||||
});
|
||||
if (result.activeTabId) setActiveTabId(result.activeTabId);
|
||||
return result.sessions;
|
||||
});
|
||||
}, [logViews, setActiveTabId]);
|
||||
|
||||
const reorderTabs = useCallback((
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
@@ -896,39 +951,13 @@ export const useSessionState = () => {
|
||||
) => {
|
||||
if (draggedId === targetId) return;
|
||||
|
||||
setTabOrder(prevTabOrder => {
|
||||
const allTabIds = [...baseWorkTabIds, ...additionalTabIds];
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
|
||||
// Build current effective order: existing order + new tabs at end
|
||||
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
|
||||
const currentOrder = [...orderedIds, ...newIds];
|
||||
|
||||
const draggedIndex = currentOrder.indexOf(draggedId);
|
||||
const targetIndex = currentOrder.indexOf(targetId);
|
||||
|
||||
if (draggedIndex === -1 || targetIndex === -1) return prevTabOrder;
|
||||
|
||||
// Remove dragged item first
|
||||
currentOrder.splice(draggedIndex, 1);
|
||||
|
||||
// Calculate new target index (adjusted after removal)
|
||||
let newTargetIndex = targetIndex;
|
||||
if (draggedIndex < targetIndex) {
|
||||
newTargetIndex -= 1;
|
||||
}
|
||||
|
||||
// Insert at the correct position
|
||||
if (position === 'after') {
|
||||
newTargetIndex += 1;
|
||||
}
|
||||
|
||||
currentOrder.splice(newTargetIndex, 0, draggedId);
|
||||
|
||||
return currentOrder;
|
||||
});
|
||||
setTabOrder(prevTabOrder => reorderWorkTabIds(
|
||||
prevTabOrder,
|
||||
[...baseWorkTabIds, ...additionalTabIds],
|
||||
draggedId,
|
||||
targetId,
|
||||
position,
|
||||
));
|
||||
}, [baseWorkTabIds]);
|
||||
|
||||
return {
|
||||
@@ -942,6 +971,7 @@ export const useSessionState = () => {
|
||||
sessionRenameValue,
|
||||
setSessionRenameValue,
|
||||
startSessionRename,
|
||||
renameSessionInline,
|
||||
submitSessionRename,
|
||||
resetSessionRename,
|
||||
workspaceRenameTarget,
|
||||
@@ -962,6 +992,7 @@ export const useSessionState = () => {
|
||||
createWorkspaceFromTargets,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
removeSessionFromWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
updateSplitSizes,
|
||||
|
||||
@@ -19,6 +19,7 @@ export const DISTRO_LOGOS: Record<string, string> = {
|
||||
kali: "/distro/kali.svg",
|
||||
almalinux: "/distro/almalinux.svg",
|
||||
alinux: "/distro/alinux.svg",
|
||||
openeuler: "/distro/openeuler.svg",
|
||||
// OS-level logos (used by local terminal tab icons)
|
||||
macos: "/distro/macos.svg",
|
||||
windows: "/distro/windows.svg",
|
||||
@@ -50,6 +51,7 @@ export const DISTRO_COLORS: Record<string, string> = {
|
||||
kali: "bg-[#0F6DB3]",
|
||||
almalinux: "bg-[#173B66]",
|
||||
alinux: "bg-[#FF6A00]",
|
||||
openeuler: "bg-[#002FA7]",
|
||||
// OS-level colors
|
||||
macos: "bg-[#333333]",
|
||||
windows: "bg-[#0078D4]",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Activity, Cpu, Clock3, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles } from "lucide-react";
|
||||
import { Activity, Cpu, Clock3, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles, SquareArrowOutUpRight } from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { detectLocalOs } from "../lib/localShell";
|
||||
@@ -149,8 +149,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionLog,
|
||||
sshDebugLogEnabled,
|
||||
sudoAutofillPassword,
|
||||
showSelectionAIAction,
|
||||
showSelectionAIAction = true,
|
||||
onAddSelectionToAI,
|
||||
sessionDisplayName,
|
||||
onRename,
|
||||
onDetach,
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
onDetachPointerDown,
|
||||
onDetachDragStart,
|
||||
onDetachDragEnd,
|
||||
}) => {
|
||||
const layoutSuppressActive = useTerminalLayoutSuppressActive();
|
||||
const deferTerminalResize = isResizing || layoutSuppressActive;
|
||||
@@ -1260,7 +1268,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
|
||||
|
||||
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
|
||||
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionDisplayName, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
|
||||
};
|
||||
|
||||
const Terminal = memo(TerminalComponent, terminalPropsAreEqual);
|
||||
|
||||
@@ -124,6 +124,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onToggleWorkspaceViewMode,
|
||||
onSetWorkspaceFocusedSession,
|
||||
onReorderWorkspaceSessions,
|
||||
onReorderTabs,
|
||||
onCopySession,
|
||||
onCopySessionToNewWindow,
|
||||
onSplitSession,
|
||||
onConnectToHost,
|
||||
onCreateLocalTerminal,
|
||||
@@ -150,6 +153,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
showHostTreeSidebar = true,
|
||||
toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef,
|
||||
// Session rename props
|
||||
onStartSessionRename,
|
||||
onSubmitSessionRename,
|
||||
onRemoveSessionFromWorkspace,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const terminalRendererCwdBySessionRef = useRef<Map<string, string>>(new Map());
|
||||
@@ -1138,10 +1145,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onCreateWorkspaceFromSessions,
|
||||
onHotkeyAction,
|
||||
onReorderWorkspaceSessions,
|
||||
onReorderTabs,
|
||||
onCopySession,
|
||||
onCopySessionToNewWindow,
|
||||
onRequestAddToWorkspace,
|
||||
onSessionData,
|
||||
onSetDraggingSessionId,
|
||||
onSetWorkspaceFocusedSession,
|
||||
onStartSessionRename,
|
||||
onSubmitSessionRename,
|
||||
onRemoveSessionFromWorkspace,
|
||||
onStartSessionDrag: onSetDraggingSessionId,
|
||||
onEndSessionDrag: () => onSetDraggingSessionId(null),
|
||||
onSplitSession,
|
||||
onSplitSessionRef,
|
||||
onToggleBroadcastRef,
|
||||
|
||||
@@ -18,13 +18,23 @@ Object.defineProperty(globalThis, "requestAnimationFrame", {
|
||||
|
||||
const {
|
||||
computeHostTreeTabGutter,
|
||||
resolveWorkspaceSessionTabDropTarget,
|
||||
shouldKeepHostTreeToggleSurface,
|
||||
shouldShowHostTreeToggle,
|
||||
} = await import("./TopTabs.tsx");
|
||||
const {
|
||||
WORKSPACE_SESSION_DRAG_TYPE,
|
||||
dataTransferHasType,
|
||||
getTopTabInsertionTarget,
|
||||
getWorkspaceSessionDragId,
|
||||
hasWorkspaceSessionDrag,
|
||||
isPointInsideRect,
|
||||
} = await import("../application/state/terminalDragData.ts");
|
||||
const { activateLogViewTab } = await import("./top-tabs/TopTabItems.tsx");
|
||||
const { activeTabStore } = await import("../application/state/activeTabStore.ts");
|
||||
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
|
||||
const topTabsSource = readFileSync(new URL("./TopTabs.tsx", import.meta.url), "utf8");
|
||||
const terminalViewSource = readFileSync(new URL("./terminal/TerminalView.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("host tree tab gutter fills the remaining sidebar width", () => {
|
||||
assert.equal(computeHostTreeTabGutter(280, 120), 160);
|
||||
@@ -93,6 +103,152 @@ test("quick switcher plus button exposes a custom CSS hook", () => {
|
||||
assert.match(topTabsSource, /data-section="top-tabs-quick-switcher-toggle"/);
|
||||
});
|
||||
|
||||
test("workspace session drag data is recognized with a dedicated drag type", () => {
|
||||
const data = new Map([
|
||||
[WORKSPACE_SESSION_DRAG_TYPE, "session-1"],
|
||||
["session-id", "fallback-session"],
|
||||
]);
|
||||
const transfer = {
|
||||
types: [WORKSPACE_SESSION_DRAG_TYPE, "text/plain"],
|
||||
getData: (format: string) => data.get(format) ?? "",
|
||||
};
|
||||
|
||||
assert.equal(hasWorkspaceSessionDrag(transfer), true);
|
||||
assert.equal(getWorkspaceSessionDragId(transfer), "session-1");
|
||||
});
|
||||
|
||||
test("workspace session drag id falls back to the legacy session id", () => {
|
||||
const transfer = {
|
||||
types: ["session-id"],
|
||||
getData: (format: string) => (format === "session-id" ? "session-2" : ""),
|
||||
};
|
||||
|
||||
assert.equal(dataTransferHasType(transfer, "session-id"), true);
|
||||
assert.equal(hasWorkspaceSessionDrag(transfer), false);
|
||||
assert.equal(getWorkspaceSessionDragId(transfer), "session-2");
|
||||
});
|
||||
|
||||
test("point-in-rect detects pointer release inside the top tab bar", () => {
|
||||
const rect = { left: 10, right: 110, top: 20, bottom: 60 };
|
||||
|
||||
assert.equal(isPointInsideRect({ clientX: 10, clientY: 20 }, rect), true);
|
||||
assert.equal(isPointInsideRect({ clientX: 70, clientY: 40 }, rect), true);
|
||||
assert.equal(isPointInsideRect({ clientX: 111, clientY: 40 }, rect), false);
|
||||
assert.equal(isPointInsideRect({ clientX: 70, clientY: 61 }, rect), false);
|
||||
});
|
||||
|
||||
test("top tab insertion target ignores fixed root tabs", () => {
|
||||
const makeTab = (id: string, type: string, left: number, right: number) => ({
|
||||
dataset: { tabId: id, tabType: type },
|
||||
getBoundingClientRect: () => ({ left, right, top: 20, bottom: 60, width: right - left, height: 40 }),
|
||||
});
|
||||
const root = {
|
||||
getBoundingClientRect: () => ({ left: 0, right: 400, top: 0, bottom: 80, width: 400, height: 80 }),
|
||||
querySelectorAll: () => [
|
||||
makeTab("vault", "root", 0, 80),
|
||||
makeTab("workspace-1", "workspace", 90, 210),
|
||||
makeTab("session-1", "session", 210, 330),
|
||||
],
|
||||
} as unknown as HTMLElement;
|
||||
|
||||
assert.deepEqual(getTopTabInsertionTarget({ clientX: 20, clientY: 40 }, root), {
|
||||
tabId: "workspace-1",
|
||||
position: "before",
|
||||
});
|
||||
assert.deepEqual(getTopTabInsertionTarget({ clientX: 180, clientY: 40 }, root), {
|
||||
tabId: "workspace-1",
|
||||
position: "after",
|
||||
});
|
||||
assert.deepEqual(getTopTabInsertionTarget({ clientX: 380, clientY: 40 }, root), {
|
||||
tabId: "session-1",
|
||||
position: "after",
|
||||
});
|
||||
assert.equal(getTopTabInsertionTarget({ clientX: 180, clientY: 120 }, root), null);
|
||||
});
|
||||
|
||||
test("workspace session tab drop forwards the requested insertion target", () => {
|
||||
assert.deepEqual(resolveWorkspaceSessionTabDropTarget({
|
||||
targetTabId: "session-3",
|
||||
position: "after",
|
||||
draggedSessionId: "session-1",
|
||||
draggedWorkspaceId: "workspace-1",
|
||||
workspaces: [],
|
||||
}), {
|
||||
tabId: "session-3",
|
||||
position: "after",
|
||||
additionalTabIds: ["session-1", "session-3"],
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace session tab drop targets the remaining terminal when its workspace dissolves", () => {
|
||||
assert.deepEqual(resolveWorkspaceSessionTabDropTarget({
|
||||
targetTabId: "workspace-1",
|
||||
position: "before",
|
||||
draggedSessionId: "session-1",
|
||||
draggedWorkspaceId: "workspace-1",
|
||||
workspaces: [{
|
||||
id: "workspace-1",
|
||||
title: "Workspace",
|
||||
focusedSessionId: "session-1",
|
||||
root: {
|
||||
id: "split-1",
|
||||
type: "split",
|
||||
direction: "horizontal",
|
||||
children: [
|
||||
{ id: "pane-1", type: "pane", sessionId: "session-1" },
|
||||
{ id: "pane-2", type: "pane", sessionId: "session-2" },
|
||||
],
|
||||
sizes: [1, 1],
|
||||
},
|
||||
}],
|
||||
}), {
|
||||
tabId: "session-2",
|
||||
position: "before",
|
||||
additionalTabIds: ["session-1", "session-2"],
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace session tab-bar blank drop inserts after the last work tab", () => {
|
||||
const makeTab = (id: string, type: string, left: number, right: number) => ({
|
||||
dataset: { tabId: id, tabType: type },
|
||||
getBoundingClientRect: () => ({ left, right, top: 20, bottom: 60, width: right - left, height: 40 }),
|
||||
});
|
||||
const root = {
|
||||
getBoundingClientRect: () => ({ left: 0, right: 500, top: 0, bottom: 80, width: 500, height: 80 }),
|
||||
querySelectorAll: () => [
|
||||
makeTab("vault", "root", 0, 80),
|
||||
makeTab("workspace-1", "workspace", 90, 210),
|
||||
makeTab("session-3", "session", 210, 330),
|
||||
],
|
||||
} as unknown as HTMLElement;
|
||||
const insertionTarget = getTopTabInsertionTarget({ clientX: 460, clientY: 40 }, root);
|
||||
|
||||
assert.deepEqual(insertionTarget, { tabId: "session-3", position: "after" });
|
||||
assert.deepEqual(resolveWorkspaceSessionTabDropTarget({
|
||||
targetTabId: insertionTarget!.tabId,
|
||||
position: insertionTarget!.position,
|
||||
draggedSessionId: "session-1",
|
||||
draggedWorkspaceId: "workspace-1",
|
||||
workspaces: [],
|
||||
}), {
|
||||
tabId: "session-3",
|
||||
position: "after",
|
||||
additionalTabIds: ["session-1", "session-3"],
|
||||
});
|
||||
});
|
||||
|
||||
test("terminal top bar hides server stats before they crowd the host title", () => {
|
||||
assert.match(indexCss, /\.terminal-topbar\s*\{[\s\S]*container-type: inline-size/);
|
||||
assert.match(indexCss, /@container \(max-width: 760px\) \{[\s\S]*\.terminal-server-stats\s*\{[\s\S]*display: none/);
|
||||
assert.match(terminalViewSource, /terminal-topbar/);
|
||||
assert.match(terminalViewSource, /terminal-title-cluster/);
|
||||
assert.match(terminalViewSource, /onPointerDown=\{onDetachPointerDown\}/);
|
||||
});
|
||||
|
||||
test("workspace session drag no longer uses a full tab-bar drop zone", () => {
|
||||
assert.doesNotMatch(topTabsSource, /top-tabs-workspace-detach-drop-zone/);
|
||||
});
|
||||
|
||||
test("host tree chrome enters after theme switch settles so root labels can animate", () => {
|
||||
assert.match(topTabsSource, /hostTreeChromeReady/);
|
||||
assert.match(topTabsSource, /scheduleAfterInstantThemeSwitch\(\(\) => \{\s*cancelHostTreeChromeReadyRef\.current = null;\s*setHostTreeChromeReady\(true\);/);
|
||||
|
||||
@@ -4,7 +4,9 @@ import { fromEditorTabId, isEditorTabId, useActiveTabId } from '../application/s
|
||||
import { isHostTreeWorkTabSurface } from '../application/app/workTabSurface';
|
||||
import type { EditorTab } from '../application/state/editorTabStore';
|
||||
import { buildWorkspaceActivityMap } from '../application/state/sessionActivity';
|
||||
import { collectSessionIds } from '../domain/workspace';
|
||||
import { useSessionActivityMap } from '../application/state/sessionActivityStore';
|
||||
import { getTopTabInsertionTarget, getWorkspaceSessionDragId, hasWorkspaceSessionDrag } from '../application/state/terminalDragData';
|
||||
import {
|
||||
useTerminalHostTreeLayoutWidth,
|
||||
useTerminalHostTreeOpen,
|
||||
@@ -82,6 +84,34 @@ export function shouldKeepHostTreeToggleSurface({
|
||||
return enabled && activeWorkTabCount > 0;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceSessionTabDropTarget({
|
||||
targetTabId,
|
||||
position,
|
||||
draggedSessionId,
|
||||
draggedWorkspaceId,
|
||||
workspaces,
|
||||
}: {
|
||||
targetTabId: string;
|
||||
position: 'before' | 'after';
|
||||
draggedSessionId: string;
|
||||
draggedWorkspaceId: string;
|
||||
workspaces: readonly Workspace[];
|
||||
}): { tabId: string; position: 'before' | 'after'; additionalTabIds: readonly string[] } {
|
||||
const sourceWorkspace = workspaces.find((workspace) => workspace.id === draggedWorkspaceId);
|
||||
const remainingSessionIds = sourceWorkspace
|
||||
? collectSessionIds(sourceWorkspace.root).filter((sessionId) => sessionId !== draggedSessionId)
|
||||
: [];
|
||||
const stableTargetTabId = targetTabId === draggedWorkspaceId && remainingSessionIds.length === 1
|
||||
? remainingSessionIds[0]
|
||||
: targetTabId;
|
||||
|
||||
return {
|
||||
tabId: stableTargetTabId,
|
||||
position,
|
||||
additionalTabIds: [draggedSessionId, stableTargetTabId],
|
||||
};
|
||||
}
|
||||
|
||||
interface TopTabsProps {
|
||||
theme: 'dark' | 'light';
|
||||
hosts: Host[];
|
||||
@@ -109,6 +139,10 @@ interface TopTabsProps {
|
||||
onStartSessionDrag: (sessionId: string) => void;
|
||||
onEndSessionDrag: () => void;
|
||||
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
|
||||
onRemoveSessionFromWorkspace: (
|
||||
sessionId: string,
|
||||
tabInsertionTarget?: { tabId: string; position: 'before' | 'after'; additionalTabIds?: readonly string[] },
|
||||
) => void;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
editorTabs: readonly EditorTab[];
|
||||
@@ -143,6 +177,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
onReorderTabs,
|
||||
onRemoveSessionFromWorkspace,
|
||||
showSftpTab,
|
||||
showHostTreeSidebar,
|
||||
editorTabs,
|
||||
@@ -386,7 +421,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const syncGutter = () => updateHostTreeTabGutterRef.current();
|
||||
syncGutter({ deferClose: true });
|
||||
updateHostTreeTabGutterRef.current({ deferClose: true });
|
||||
const rafId = window.requestAnimationFrame(() => syncGutter());
|
||||
const settleTimer = window.setTimeout(syncGutter, 320);
|
||||
const root = tabsContainerRef.current?.closest('[data-top-tabs-root]') as HTMLElement | null;
|
||||
@@ -442,6 +477,11 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
|
||||
setDropIndicator(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!draggedTabIdRef.current || draggedTabIdRef.current === tabId) {
|
||||
return;
|
||||
}
|
||||
@@ -463,6 +503,26 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
const handleTabDrop = useCallback((e: React.DragEvent, targetTabId: string) => {
|
||||
e.preventDefault();
|
||||
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
|
||||
const draggedSessionId = getWorkspaceSessionDragId(e.dataTransfer);
|
||||
const draggedSession = sessions.find((s) => s.id === draggedSessionId);
|
||||
if (draggedSession?.workspaceId) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const position: 'before' | 'after' = e.clientX < rect.left + rect.width / 2 ? 'before' : 'after';
|
||||
onRemoveSessionFromWorkspace(draggedSessionId, resolveWorkspaceSessionTabDropTarget({
|
||||
targetTabId,
|
||||
position,
|
||||
draggedSessionId,
|
||||
draggedWorkspaceId: draggedSession.workspaceId,
|
||||
workspaces,
|
||||
}));
|
||||
setDropIndicator(null);
|
||||
setIsDraggingForReorder(false);
|
||||
onEndSessionDrag();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const draggedId = e.dataTransfer.getData('tab-reorder-id') || draggedTabIdRef.current;
|
||||
|
||||
if (draggedId && draggedId !== targetTabId && dropIndicator) {
|
||||
@@ -471,7 +531,33 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
setDropIndicator(null);
|
||||
setIsDraggingForReorder(false);
|
||||
}, [dropIndicator, onReorderTabs]);
|
||||
}, [dropIndicator, onEndSessionDrag, onRemoveSessionFromWorkspace, onReorderTabs, sessions, workspaces]);
|
||||
|
||||
const handleTabBarDrop = useCallback((e: React.DragEvent) => {
|
||||
if (!hasWorkspaceSessionDrag(e.dataTransfer)) return;
|
||||
const draggedSessionId = getWorkspaceSessionDragId(e.dataTransfer);
|
||||
if (!draggedSessionId) return;
|
||||
const draggedSession = sessions.find((s) => s.id === draggedSessionId);
|
||||
if (!draggedSession?.workspaceId) return;
|
||||
e.preventDefault();
|
||||
const root = e.currentTarget.closest('[data-top-tabs-root]') as HTMLElement | null;
|
||||
const insertionTarget = getTopTabInsertionTarget(e, root);
|
||||
onRemoveSessionFromWorkspace(
|
||||
draggedSessionId,
|
||||
insertionTarget
|
||||
? resolveWorkspaceSessionTabDropTarget({
|
||||
targetTabId: insertionTarget.tabId,
|
||||
position: insertionTarget.position,
|
||||
draggedSessionId,
|
||||
draggedWorkspaceId: draggedSession.workspaceId,
|
||||
workspaces,
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
setDropIndicator(null);
|
||||
setIsDraggingForReorder(false);
|
||||
onEndSessionDrag();
|
||||
}, [onEndSessionDrag, onRemoveSessionFromWorkspace, sessions, workspaces]);
|
||||
|
||||
const handleScrollableTabClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
@@ -682,6 +768,14 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
const shiftStyle = tabShiftStyles[workspace.id] || emptyTabStyle;
|
||||
const showDropIndicatorBefore = dropIndicator?.tabId === workspace.id && dropIndicator.position === 'before';
|
||||
const showDropIndicatorAfter = dropIndicator?.tabId === workspace.id && dropIndicator.position === 'after';
|
||||
const workspaceSessionIds = collectSessionIds(workspace.root);
|
||||
const workspaceSessionLabels: Record<string, string> = {};
|
||||
for (const sessionId of workspaceSessionIds) {
|
||||
const wsSession = sessions.find((s) => s.id === sessionId);
|
||||
if (wsSession) {
|
||||
workspaceSessionLabels[sessionId] = wsSession.customName || wsSession.hostLabel;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceTopTab
|
||||
@@ -701,6 +795,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onTabDrop={handleTabDrop}
|
||||
onRenameWorkspace={onRenameWorkspace}
|
||||
onCloseWorkspace={onCloseWorkspace}
|
||||
onDetachSessionFromWorkspace={(_workspaceId, sessionId) => onRemoveSessionFromWorkspace(sessionId)}
|
||||
workspaceSessionLabels={workspaceSessionLabels}
|
||||
renderBulkCloseItems={renderBulkCloseItems}
|
||||
t={t}
|
||||
tabAnimationClass={getTabAnimationClass(workspace.id)}
|
||||
@@ -801,12 +897,18 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
style={dragRegionStyle}
|
||||
// Add container-level drag handlers to prevent indicator loss
|
||||
onDragOver={(e) => {
|
||||
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
return;
|
||||
}
|
||||
// Keep drop indicator active while dragging over the container
|
||||
if (draggedTabIdRef.current && isDraggingForReorder && !dropIndicator) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
}}
|
||||
onDrop={handleTabBarDrop}
|
||||
>
|
||||
{hasHostTreeToggleSurface && (
|
||||
<div
|
||||
@@ -871,6 +973,13 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
className="flex items-end gap-0 overflow-x-auto scrollbar-none app-drag max-w-full"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
onClick={handleScrollableTabClick}
|
||||
onDragOver={(e) => {
|
||||
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
}}
|
||||
onDrop={handleTabBarDrop}
|
||||
>
|
||||
{renderOrderedTabs()}
|
||||
{/* Add new tab button - follows last tab when not overflowing */}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSystemManagerBackend } from '../../application/state/useSystemManage
|
||||
import type { TerminalSettings } from '../../domain/models';
|
||||
import type { Host } from '../../domain/models/connection';
|
||||
import type { SystemManagerSubTab } from '../../domain/systemManager/types';
|
||||
import { resolveCapabilityPanelState } from '../../domain/systemManagerPanelState';
|
||||
import { buildSystemManagerTabs } from '../../domain/systemManager/systemTarget';
|
||||
import type { Snippet, TerminalSession } from '../../types';
|
||||
import { cn } from '../../lib/utils';
|
||||
@@ -50,7 +51,9 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
const sessionId = session?.id ?? null;
|
||||
const isConnected = session?.status === 'connected';
|
||||
|
||||
const { capabilities, probing } = useSessionCapabilities(sessionId, isConnected, backend, isVisible);
|
||||
const capabilitiesTtlMs = terminalSettings.systemManagerProcessRefreshInterval * 1000;
|
||||
|
||||
const { capabilities, refreshCapabilities } = useSessionCapabilities(sessionId, isConnected, backend, isVisible, capabilitiesTtlMs);
|
||||
|
||||
const availableTabs = useMemo(
|
||||
() => buildSystemManagerTabs(sessionHost, capabilities, session),
|
||||
@@ -60,6 +63,69 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
const [activeTab, setActiveTab] = useState<SystemManagerSubTab>('processes');
|
||||
const resolvedTab = availableTabs.includes(activeTab) ? activeTab : 'processes';
|
||||
|
||||
// Must be defined before early returns to comply with React rules of hooks.
|
||||
const prevTabRef = React.useRef(resolvedTab);
|
||||
const probingRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
const prev = prevTabRef.current;
|
||||
prevTabRef.current = resolvedTab;
|
||||
if (prev === resolvedTab) return;
|
||||
if (resolvedTab === 'docker' && capabilities?.hasDocker !== true) {
|
||||
if (!probingRef.current) {
|
||||
probingRef.current = true;
|
||||
refreshCapabilities().finally(() => { probingRef.current = false; });
|
||||
}
|
||||
} else if (resolvedTab === 'tmux' && capabilities?.hasTmux !== true) {
|
||||
void refreshCapabilities();
|
||||
}
|
||||
}, [resolvedTab, capabilities, refreshCapabilities]);
|
||||
|
||||
// Auto-poll for Docker capabilities while Docker tab is active and Docker not yet detected.
|
||||
// Use setTimeout recursion so the next probe only starts after the previous one finishes,
|
||||
// avoiding overlapping probes (e.g. SSH timeout 8s vs user-configured interval 2s).
|
||||
// First poll is delayed by one interval to avoid overlapping with the tab-switch probe above.
|
||||
//
|
||||
// Use a ref to store refreshCapabilities so that if its reference changes on every render,
|
||||
// the useEffect below is NOT re-run (which would cancel the timer and bypass the interval).
|
||||
const refreshRef = React.useRef(refreshCapabilities);
|
||||
refreshRef.current = refreshCapabilities;
|
||||
|
||||
// Auto-poll for Docker capabilities while Docker tab is active and Docker not yet detected.
|
||||
// Each effect generation gets its own cancelled flag and timerId via closure,
|
||||
// preventing stale probes from surviving cleanup (unlike cancelledRef which is shared).
|
||||
// First poll is delayed by one interval to avoid overlapping with the tab-switch probe.
|
||||
React.useEffect(() => {
|
||||
if (!isVisible || resolvedTab !== 'docker' || capabilities?.hasDocker === true) return;
|
||||
|
||||
let cancelled = false;
|
||||
let timerId: ReturnType<typeof setTimeout>;
|
||||
|
||||
const pollOnce = async () => {
|
||||
if (cancelled) return;
|
||||
if (probingRef.current) {
|
||||
// probe is in-flight, reschedule for next cycle
|
||||
timerId = setTimeout(pollOnce, capabilitiesTtlMs);
|
||||
return;
|
||||
}
|
||||
probingRef.current = true;
|
||||
try {
|
||||
await refreshRef.current();
|
||||
} catch {
|
||||
// Transient error - keep polling next round
|
||||
}
|
||||
probingRef.current = false;
|
||||
if (cancelled) return;
|
||||
timerId = setTimeout(pollOnce, capabilitiesTtlMs);
|
||||
};
|
||||
|
||||
timerId = setTimeout(pollOnce, capabilitiesTtlMs);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [isVisible, resolvedTab, capabilities?.hasDocker, capabilitiesTtlMs]);
|
||||
|
||||
const workspaceHostHeader = showWorkspaceHostHeader && sessionHost ? (
|
||||
<WorkspaceSidebarHostHeader
|
||||
host={sessionHost}
|
||||
@@ -93,10 +159,16 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
|
||||
const tmuxReady = capabilities?.hasTmux === true;
|
||||
const dockerReady = capabilities?.hasDocker === true;
|
||||
const tmuxUnavailable = !probing && capabilities !== undefined && !tmuxReady;
|
||||
const dockerUnavailable = !probing && capabilities !== undefined && !dockerReady;
|
||||
const tmuxChecking = resolvedTab === 'tmux' && !tmuxReady && !tmuxUnavailable;
|
||||
const dockerChecking = resolvedTab === 'docker' && !dockerReady && !dockerUnavailable;
|
||||
const tmuxPanelState = resolveCapabilityPanelState({
|
||||
isActive: resolvedTab === 'tmux',
|
||||
ready: tmuxReady,
|
||||
capabilitiesKnown: capabilities !== undefined,
|
||||
});
|
||||
const dockerPanelState = resolveCapabilityPanelState({
|
||||
isActive: resolvedTab === 'docker',
|
||||
ready: dockerReady,
|
||||
capabilitiesKnown: capabilities !== undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<SystemPanelShell section="system-manager-panel">
|
||||
@@ -129,15 +201,15 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
refreshIntervalSec={terminalSettings.systemManagerProcessRefreshInterval}
|
||||
/>
|
||||
</div>
|
||||
{tmuxUnavailable && resolvedTab === 'tmux' ? (
|
||||
{tmuxPanelState === 'unavailable' ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<SystemPanelEmpty icon={TerminalSquare} message={t('systemManager.tmux.unavailable')} />
|
||||
</div>
|
||||
) : tmuxChecking ? (
|
||||
) : tmuxPanelState === 'checking' ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
|
||||
</div>
|
||||
) : tmuxReady ? (
|
||||
) : tmuxPanelState === 'ready' ? (
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'tmux' && 'hidden')}>
|
||||
<TmuxManagerTab
|
||||
sessionId={sessionId}
|
||||
@@ -150,15 +222,15 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{dockerUnavailable && resolvedTab === 'docker' ? (
|
||||
{dockerPanelState === 'unavailable' ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<SystemPanelEmpty icon={Box} message={t('systemManager.docker.unavailable')} />
|
||||
</div>
|
||||
) : dockerChecking ? (
|
||||
) : dockerPanelState === 'checking' ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
|
||||
</div>
|
||||
) : dockerReady ? (
|
||||
) : dockerPanelState === 'ready' ? (
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'docker' && 'hidden')}>
|
||||
<DockerManagerTab
|
||||
sessionId={sessionId}
|
||||
|
||||
@@ -37,7 +37,11 @@ export function useSessionCapabilities(
|
||||
isConnected: boolean,
|
||||
backend: Backend,
|
||||
enabled: boolean,
|
||||
capabilitiesTtlMs: number,
|
||||
) {
|
||||
const ttlMsRef = useRef(capabilitiesTtlMs);
|
||||
ttlMsRef.current = capabilitiesTtlMs;
|
||||
|
||||
const [capabilities, setCapabilities] = useState<SessionCapabilities | undefined>(
|
||||
() => (sessionId ? sessionCapabilitiesStore.get(sessionId) : undefined),
|
||||
);
|
||||
@@ -63,7 +67,7 @@ export function useSessionCapabilities(
|
||||
try {
|
||||
const result = await backend.probeSystemCapabilities(sessionId);
|
||||
if (result.success && result.capabilities) {
|
||||
sessionCapabilitiesStore.set(sessionId, result.capabilities);
|
||||
sessionCapabilitiesStore.set(sessionId, result.capabilities, ttlMsRef.current);
|
||||
}
|
||||
} finally {
|
||||
setProbing(false);
|
||||
@@ -84,10 +88,13 @@ export function useSystemCapabilitiesWarmup(
|
||||
sessionIds: string[],
|
||||
backend: Backend,
|
||||
enabled: boolean,
|
||||
capabilitiesTtlMs: number,
|
||||
) {
|
||||
const backendRef = useRef(backend);
|
||||
backendRef.current = backend;
|
||||
const inflightRef = useRef(new Set<string>());
|
||||
const ttlMsRef = useRef(capabilitiesTtlMs);
|
||||
ttlMsRef.current = capabilitiesTtlMs;
|
||||
|
||||
const sessionKey = enabled ? sessionIds.slice().sort().join(',') : '';
|
||||
|
||||
@@ -100,7 +107,7 @@ export function useSystemCapabilitiesWarmup(
|
||||
void backendRef.current.probeSystemCapabilities(sessionId).then((result) => {
|
||||
inflightRef.current.delete(sessionId);
|
||||
if (result.success && result.capabilities) {
|
||||
sessionCapabilitiesStore.set(sessionId, result.capabilities);
|
||||
sessionCapabilitiesStore.set(sessionId, result.capabilities, ttlMsRef.current);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
81
components/terminal/SessionInlineRenameInput.tsx
Normal file
81
components/terminal/SessionInlineRenameInput.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
type SessionInlineRenameInputProps = {
|
||||
initialName: string;
|
||||
onCommit: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export const SessionInlineRenameInput: React.FC<SessionInlineRenameInputProps> = ({
|
||||
initialName,
|
||||
onCommit,
|
||||
onCancel,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState(initialName);
|
||||
const committedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
input.select();
|
||||
}, []);
|
||||
|
||||
const commit = () => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
onCommit(value);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-session-inline-rename="true"
|
||||
value={value}
|
||||
draggable={false}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={() => {
|
||||
queueMicrotask(() => {
|
||||
commit();
|
||||
});
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate select-text rounded-sm border border-primary/50 bg-background/80 px-1 py-0 text-sm font-medium outline-none ring-1 ring-primary/30',
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
ClipboardPaste,
|
||||
Copy,
|
||||
Download,
|
||||
Pencil,
|
||||
RefreshCcw,
|
||||
Sparkles,
|
||||
SquareArrowOutUpRight,
|
||||
SplitSquareHorizontal,
|
||||
SplitSquareVertical,
|
||||
Terminal as TerminalIcon,
|
||||
@@ -48,6 +50,8 @@ export interface TerminalContextMenuProps {
|
||||
onClose?: () => void;
|
||||
onSelectWord?: () => void;
|
||||
onAddSelectionToAI?: () => void;
|
||||
onRename?: () => void;
|
||||
onDetach?: () => void;
|
||||
}
|
||||
|
||||
export const shouldShowReconnectAction = ({
|
||||
@@ -125,6 +129,8 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
onClose,
|
||||
onSelectWord,
|
||||
onAddSelectionToAI,
|
||||
onRename,
|
||||
onDetach,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
@@ -299,6 +305,26 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
<ContextMenuShortcut>{clearShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
|
||||
{onRename && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onRename}>
|
||||
<Pencil size={14} className="mr-2" />
|
||||
{t('terminal.menu.rename')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onDetach && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onDetach}>
|
||||
<SquareArrowOutUpRight size={14} className="mr-2" />
|
||||
{t('terminal.menu.detach')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onClose && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
|
||||
@@ -47,16 +47,16 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
||||
if (!enabled || !isConnected || !serverStats.lastUpdated) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 ml-2 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0">
|
||||
<div className="terminal-server-stats flex items-center gap-2 ml-1 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0 shrink">
|
||||
{/* CPU with HoverCard for per-core details */}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
|
||||
aria-label={t("terminal.serverStats.cpu")}
|
||||
>
|
||||
<Cpu size={10} className="flex-shrink-0" />
|
||||
<span>
|
||||
<span className="truncate">
|
||||
{serverStats.cpu !== null ? `${serverStats.cpu}%` : '--'}
|
||||
{serverStats.cpuCores !== null && ` (${serverStats.cpuCores}C)`}
|
||||
</span>
|
||||
@@ -121,11 +121,11 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
|
||||
aria-label={t("terminal.serverStats.memory")}
|
||||
>
|
||||
<MemoryStick size={10} className="flex-shrink-0" />
|
||||
<span>
|
||||
<span className="truncate">
|
||||
{serverStats.memUsed !== null && serverStats.memTotal !== null
|
||||
? `${(serverStats.memUsed / 1024).toFixed(1)}/${(serverStats.memTotal / 1024).toFixed(1)}G`
|
||||
: '--'}
|
||||
@@ -248,11 +248,12 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
|
||||
aria-label={t("terminal.serverStats.disk")}
|
||||
>
|
||||
<HardDrive size={10} className="flex-shrink-0" />
|
||||
<span className={cn(
|
||||
"truncate",
|
||||
serverStats.diskPercent !== null && serverStats.diskPercent >= 90 && "text-red-400",
|
||||
serverStats.diskPercent !== null && serverStats.diskPercent >= 80 && serverStats.diskPercent < 90 && "text-amber-400"
|
||||
)}>
|
||||
@@ -315,13 +316,13 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
||||
className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
|
||||
aria-label={t("terminal.serverStats.network")}
|
||||
>
|
||||
<ArrowDownToLine size={9} className="flex-shrink-0 text-emerald-400" />
|
||||
<span>{formatNetSpeed(serverStats.netRxSpeed)}</span>
|
||||
<span className="truncate">{formatNetSpeed(serverStats.netRxSpeed)}</span>
|
||||
<ArrowUpFromLine size={9} className="flex-shrink-0 text-sky-400" />
|
||||
<span>{formatNetSpeed(serverStats.netTxSpeed)}</span>
|
||||
<span className="truncate">{formatNetSpeed(serverStats.netTxSpeed)}</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
|
||||
@@ -88,7 +88,7 @@ function terminalViewCtxEqual(
|
||||
}
|
||||
|
||||
function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
const { Activity, Button, Clock3, Copy, Maximize2, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
|
||||
const { Activity, Button, Clock3, Copy, Maximize2, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, searchMatchCount, selectionOverlayPosition, sessionDisplayName, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
|
||||
const ymodemActionEnabled = shouldEnableYmodemAction({
|
||||
isSerialConnection,
|
||||
status,
|
||||
@@ -133,6 +133,8 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
onReconnect={handleRetry}
|
||||
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
|
||||
onAddSelectionToAI={ctx.onAddSelectionToAI ? handleAddSelectionToAI : undefined}
|
||||
onRename={onRename}
|
||||
onDetach={inWorkspace ? onDetach : undefined}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -170,7 +172,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
)}
|
||||
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
|
||||
className="terminal-topbar flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
|
||||
onMouseDownCapture={handleTopOverlayMouseDownCapture}
|
||||
style={{
|
||||
backgroundColor: 'var(--terminal-ui-bg)',
|
||||
@@ -183,14 +185,27 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
['--terminal-toolbar-btn-active' as never]: 'var(--terminal-ui-toolbar-btn-active)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-[11px] font-semibold min-w-0">
|
||||
<span className="whitespace-nowrap truncate">{host.label}</span>
|
||||
<span
|
||||
<div
|
||||
className={cn(
|
||||
"terminal-title-cluster flex items-center gap-1 text-[11px] font-semibold min-w-0 overflow-hidden shrink",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-block h-2 w-2 rounded-full flex-shrink-0",
|
||||
statusDotTone,
|
||||
"flex items-center gap-1 min-w-0",
|
||||
inWorkspace && onDetachPointerDown && "cursor-grab active:cursor-grabbing",
|
||||
)}
|
||||
/>
|
||||
data-terminal-detach-drag-handle={inWorkspace && onDetachPointerDown ? "true" : undefined}
|
||||
onPointerDown={onDetachPointerDown}
|
||||
>
|
||||
<span className="whitespace-nowrap truncate min-w-0 max-w-[12rem]">{sessionDisplayName || host.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2 w-2 rounded-full flex-shrink-0",
|
||||
statusDotTone,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{shouldShowLineTimestampToolbarToggle(lineTimestampsAvailable, onUpdateHost) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -266,7 +281,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<div className="flex-1 min-w-0" />
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{inWorkspace && onToggleBroadcast && (
|
||||
<Tooltip>
|
||||
@@ -296,6 +311,22 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{inWorkspace && onDetach && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
|
||||
onClick={onDetach}
|
||||
aria-label={t('terminal.toolbar.detach')}
|
||||
>
|
||||
<SquareArrowOutUpRight size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('terminal.toolbar.detach')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{inWorkspace && !isFocusMode && onExpandToFocus && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
shouldHandleTerminalFontSizeAction,
|
||||
terminalFontSizeWheelListenerOptions,
|
||||
} from "./terminalFontZoom";
|
||||
import { shouldPassThroughCopyShortcut } from "./terminalCopyShortcut";
|
||||
import {
|
||||
markExpectedTerminalCursorPositionReport,
|
||||
pasteTextIntoTerminal,
|
||||
@@ -704,6 +705,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// When copy is bound specifically to Ctrl+C and there is no text
|
||||
// selected, pass the event through so xterm can send SIGINT.
|
||||
if (shouldPassThroughCopyShortcut(action, term.hasSelection(), e)) {
|
||||
return true;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
switch (action) {
|
||||
|
||||
56
components/terminal/runtime/terminalCopyShortcut.test.ts
Normal file
56
components/terminal/runtime/terminalCopyShortcut.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
isPlainCtrlCInterruptChord,
|
||||
shouldPassThroughCopyShortcut,
|
||||
} from "./terminalCopyShortcut.ts";
|
||||
|
||||
const keyboardEvent = (
|
||||
key: string,
|
||||
code: string,
|
||||
modifiers: Partial<KeyboardEvent> = {},
|
||||
): KeyboardEvent => ({
|
||||
key,
|
||||
code,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
...modifiers,
|
||||
}) as KeyboardEvent;
|
||||
|
||||
test("plain Ctrl+C copy with no selection passes through for SIGINT", () => {
|
||||
const event = keyboardEvent("c", "KeyC", { ctrlKey: true });
|
||||
|
||||
assert.equal(isPlainCtrlCInterruptChord(event), true);
|
||||
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
|
||||
});
|
||||
|
||||
test("copy shortcut does not pass through when text is selected", () => {
|
||||
const event = keyboardEvent("c", "KeyC", { ctrlKey: true });
|
||||
|
||||
assert.equal(shouldPassThroughCopyShortcut("copy", true, event), false);
|
||||
});
|
||||
|
||||
test("copy shortcut does not pass through for shifted or alternate chords", () => {
|
||||
assert.equal(
|
||||
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("C", "KeyC", { ctrlKey: true, shiftKey: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("l", "KeyL", { ctrlKey: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldPassThroughCopyShortcut("paste", false, keyboardEvent("c", "KeyC", { ctrlKey: true })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("plain Ctrl+C copy passthrough follows the physical C key on non-Latin layouts", () => {
|
||||
const event = keyboardEvent("\u0441", "KeyC", { ctrlKey: true });
|
||||
|
||||
assert.equal(isPlainCtrlCInterruptChord(event), true);
|
||||
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
|
||||
});
|
||||
20
components/terminal/runtime/terminalCopyShortcut.ts
Normal file
20
components/terminal/runtime/terminalCopyShortcut.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
type CopyShortcutKeyEvent = Pick<
|
||||
KeyboardEvent,
|
||||
"key" | "code" | "ctrlKey" | "shiftKey" | "altKey" | "metaKey"
|
||||
>;
|
||||
|
||||
export function isPlainCtrlCInterruptChord(e: CopyShortcutKeyEvent): boolean {
|
||||
return e.ctrlKey
|
||||
&& !e.shiftKey
|
||||
&& !e.altKey
|
||||
&& !e.metaKey
|
||||
&& (e.key.toLowerCase() === "c" || e.code === "KeyC");
|
||||
}
|
||||
|
||||
export function shouldPassThroughCopyShortcut(
|
||||
action: string,
|
||||
hasSelection: boolean,
|
||||
e: CopyShortcutKeyEvent,
|
||||
): boolean {
|
||||
return action === "copy" && !hasSelection && isPlainCtrlCInterruptChord(e);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DragEvent, PointerEvent } from "react";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
import { logger } from "../../lib/logger";
|
||||
@@ -17,6 +18,14 @@ import type {
|
||||
|
||||
export const MAX_CONNECTION_LOG_DATA_CHARS = 1_000_000;
|
||||
|
||||
/**
|
||||
* Get the display name for a terminal session.
|
||||
* Uses customName if set, otherwise falls back to hostLabel.
|
||||
*/
|
||||
export function getSessionDisplayName(session: TerminalSession): string {
|
||||
return session.customName || session.hostLabel || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract unique root paths from drop entries for local terminal path insertion.
|
||||
* For nested files, extracts the root folder path; for single files, uses the full path.
|
||||
@@ -170,6 +179,17 @@ export interface TerminalProps {
|
||||
sudoAutofillPassword?: string;
|
||||
showSelectionAIAction?: boolean;
|
||||
onAddSelectionToAI?: (sessionId: string, selection: string) => void;
|
||||
/** Override display name for the pane title bar (customName || hostLabel) */
|
||||
sessionDisplayName?: string;
|
||||
/** Open rename dialog for this session */
|
||||
onRename?: () => void;
|
||||
/** Detach this session from its workspace to a standalone tab */
|
||||
onDetach?: () => void;
|
||||
onStartSessionDrag?: (sessionId: string) => void;
|
||||
onEndSessionDrag?: () => void;
|
||||
onDetachPointerDown?: (e: PointerEvent<HTMLElement>) => void;
|
||||
onDetachDragStart?: (e: DragEvent) => void;
|
||||
onDetachDragEnd?: (e: DragEvent) => void;
|
||||
}
|
||||
|
||||
export function formatNetSpeed(bytesPerSec: number): string {
|
||||
|
||||
@@ -33,6 +33,7 @@ export const terminalPropsAreEqual = (
|
||||
&& prev.customAccent === next.customAccent
|
||||
&& prev.terminalSettings === next.terminalSettings
|
||||
&& prev.sessionId === next.sessionId
|
||||
&& prev.sessionDisplayName === next.sessionDisplayName
|
||||
&& prev.startupCommand === next.startupCommand
|
||||
&& prev.noAutoRun === next.noAutoRun
|
||||
&& prev.reuseConnectionFromSessionId === next.reuseConnectionFromSessionId
|
||||
@@ -71,4 +72,11 @@ export const terminalPropsAreEqual = (
|
||||
&& prev.onBroadcastInput === next.onBroadcastInput
|
||||
&& prev.onSnippetExecutorChange === next.onSnippetExecutorChange
|
||||
&& prev.onAddSelectionToAI === next.onAddSelectionToAI
|
||||
&& prev.onRename === next.onRename
|
||||
&& prev.onDetach === next.onDetach
|
||||
&& prev.onStartSessionDrag === next.onStartSessionDrag
|
||||
&& prev.onEndSessionDrag === next.onEndSessionDrag
|
||||
&& prev.onDetachPointerDown === next.onDetachPointerDown
|
||||
&& prev.onDetachDragStart === next.onDetachDragStart
|
||||
&& prev.onDetachDragEnd === next.onDetachDragEnd
|
||||
);
|
||||
|
||||
@@ -62,3 +62,18 @@ test("allows native focus for contenteditable regions", () => {
|
||||
|
||||
assert.equal(shouldPreserveTerminalFocusOnMouseDown(editableTarget as unknown as EventTarget), false);
|
||||
});
|
||||
|
||||
test("allows native drag start from the terminal detach drag handle", () => {
|
||||
const dragHandleTarget = {
|
||||
tagName: "span",
|
||||
isContentEditable: false,
|
||||
closest(selector: string) {
|
||||
return selector.includes("data-terminal-detach-drag-handle") ? { tagName: "DIV" } : null;
|
||||
},
|
||||
getAttribute() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(shouldPreserveTerminalFocusOnMouseDown(dragHandleTarget as unknown as EventTarget), false);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ type FocusTargetLike = {
|
||||
};
|
||||
|
||||
const EDITABLE_SELECTOR = 'input, textarea, select, [contenteditable=""], [contenteditable="true"], [role="textbox"]';
|
||||
const NATIVE_POINTER_SELECTOR = `${EDITABLE_SELECTOR}, [data-terminal-detach-drag-handle="true"]`;
|
||||
|
||||
/**
|
||||
* The terminal's top overlay sits above the xterm textarea. Pointer clicks on
|
||||
@@ -31,12 +32,16 @@ export const shouldPreserveTerminalFocusOnMouseDown = (target: EventTarget | nul
|
||||
if (typeof candidate.getAttribute === "function") {
|
||||
const contentEditable = candidate.getAttribute("contenteditable");
|
||||
const role = candidate.getAttribute("role");
|
||||
const detachDragHandle = candidate.getAttribute("data-terminal-detach-drag-handle");
|
||||
if (contentEditable === "" || contentEditable === "true" || role === "textbox") {
|
||||
return false;
|
||||
}
|
||||
if (detachDragHandle === "true") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof candidate.closest === "function" && candidate.closest(EDITABLE_SELECTOR)) {
|
||||
if (typeof candidate.closest === "function" && candidate.closest(NATIVE_POINTER_SELECTOR)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import { STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH } from '../../infrastructure/
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { SessionInlineRenameInput } from '../terminal/SessionInlineRenameInput';
|
||||
import { SessionTabContextMenuContent } from '../top-tabs/SessionTabContextMenuContent';
|
||||
import { Button } from '../ui/button';
|
||||
import { ContextMenu, ContextMenuTrigger } from '../ui/context-menu';
|
||||
import { Input } from '../ui/input';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
@@ -17,8 +20,13 @@ interface TerminalFocusSidebarProps {
|
||||
focusedSessionId: string | undefined;
|
||||
onReorderWorkspaceSessions?: (workspaceId: string, draggedSessionId: string, targetSessionId: string, position: 'before' | 'after') => void;
|
||||
onRequestAddToWorkspace?: (workspaceId: string) => void;
|
||||
onCloseSession: (sessionId: string) => void;
|
||||
onCopySession?: (sessionId: string) => void;
|
||||
onCopySessionToNewWindow?: (sessionId: string) => void;
|
||||
onDetachSessionFromWorkspace?: (sessionId: string) => void;
|
||||
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
|
||||
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
|
||||
onSubmitSessionRename: (sessionId: string, name: string) => void;
|
||||
resolvedPreviewTheme: TerminalTheme;
|
||||
sessionHostsMap: Map<string, Host>;
|
||||
sessions: TerminalSession[];
|
||||
@@ -40,6 +48,15 @@ type WorkspaceFocusSessionRowProps = {
|
||||
session: TerminalSession;
|
||||
host: Host | undefined;
|
||||
isSelected: boolean;
|
||||
isRenaming: boolean;
|
||||
renameValue: string;
|
||||
onStartRename: (sessionId: string) => void;
|
||||
onSubmitRename: (name: string) => void;
|
||||
onCancelRename: () => void;
|
||||
onCloseSession: (sessionId: string) => void;
|
||||
onCopySession?: (sessionId: string) => void;
|
||||
onCopySessionToNewWindow?: (sessionId: string) => void;
|
||||
onDetachSessionFromWorkspace?: (sessionId: string) => void;
|
||||
isDragging: boolean;
|
||||
dropPosition: 'before' | 'after' | null;
|
||||
theme: FocusSidebarTheme;
|
||||
@@ -48,12 +65,22 @@ type WorkspaceFocusSessionRowProps = {
|
||||
onDragOver: (event: DragEvent, sessionId: string) => void;
|
||||
onDrop: (event: DragEvent, sessionId: string) => void;
|
||||
onDragEnd: () => void;
|
||||
t: (key: string) => string;
|
||||
};
|
||||
|
||||
const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
|
||||
session,
|
||||
host,
|
||||
isSelected,
|
||||
isRenaming,
|
||||
renameValue,
|
||||
onStartRename,
|
||||
onSubmitRename,
|
||||
onCancelRename,
|
||||
onCloseSession,
|
||||
onCopySession,
|
||||
onCopySessionToNewWindow,
|
||||
onDetachSessionFromWorkspace,
|
||||
isDragging,
|
||||
dropPosition,
|
||||
theme,
|
||||
@@ -62,6 +89,7 @@ const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
t,
|
||||
}) => {
|
||||
const {
|
||||
termFg,
|
||||
@@ -83,80 +111,121 @@ const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
|
||||
const rowFg = isSelected ? termFg : unselectedFg;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-workspace-focus-session-id={session.id}
|
||||
draggable
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'relative flex w-full select-none items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm font-normal outline-none transition-colors hover:text-inherit focus-visible:ring-1',
|
||||
isDragging && 'opacity-50',
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: restBg,
|
||||
color: rowFg,
|
||||
boxShadow: dropPosition
|
||||
? `inset 0 ${dropPosition === 'before' ? '2px' : '-2px'} 0 ${termFg}`
|
||||
: undefined,
|
||||
}}
|
||||
onDragStart={(event) => onDragStart(event, session.id)}
|
||||
onDragOver={(event) => onDragOver(event, session.id)}
|
||||
onDragLeave={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onDrop={(event) => onDrop(event, session.id)}
|
||||
onDragEnd={onDragEnd}
|
||||
onMouseEnter={(event) => {
|
||||
event.currentTarget.style.backgroundColor = hoverBg;
|
||||
}}
|
||||
onMouseLeave={(event) => {
|
||||
event.currentTarget.style.backgroundColor = restBg;
|
||||
}}
|
||||
onClick={() => onSelect(session.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
onSelect(session.id);
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center self-center">
|
||||
{host ? (
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={session.hostLabel}
|
||||
size="sm"
|
||||
className="!h-6 !w-6"
|
||||
/>
|
||||
) : (
|
||||
<Server size={14} style={{ color: mutedFg }} />
|
||||
)}
|
||||
<Circle
|
||||
size={5}
|
||||
className={cn('absolute bottom-0 right-0 fill-current', statusColor)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-6 flex-1 min-w-0 flex-col justify-center self-center text-left">
|
||||
<div className={cn('truncate text-xs leading-none', isSelected ? 'font-semibold' : 'font-medium')}>
|
||||
{session.hostLabel}
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
data-workspace-focus-session-id={session.id}
|
||||
draggable
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'relative flex w-full select-none items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm font-normal outline-none transition-colors hover:text-inherit focus-visible:ring-1',
|
||||
isDragging && 'opacity-50',
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: restBg,
|
||||
color: rowFg,
|
||||
boxShadow: dropPosition
|
||||
? `inset 0 ${dropPosition === 'before' ? '2px' : '-2px'} 0 ${termFg}`
|
||||
: undefined,
|
||||
}}
|
||||
onContextMenu={() => onSelect(session.id)}
|
||||
onDragStart={(event) => onDragStart(event, session.id)}
|
||||
onDragOver={(event) => onDragOver(event, session.id)}
|
||||
onDragLeave={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onDrop={(event) => onDrop(event, session.id)}
|
||||
onDragEnd={onDragEnd}
|
||||
onMouseEnter={(event) => {
|
||||
event.currentTarget.style.backgroundColor = hoverBg;
|
||||
}}
|
||||
onMouseLeave={(event) => {
|
||||
event.currentTarget.style.backgroundColor = restBg;
|
||||
}}
|
||||
onClick={() => onSelect(session.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
onSelect(session.id);
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center self-center">
|
||||
{host ? (
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={session.hostLabel}
|
||||
size="sm"
|
||||
className="!h-6 !w-6"
|
||||
/>
|
||||
) : (
|
||||
<Server size={14} style={{ color: mutedFg }} />
|
||||
)}
|
||||
<Circle
|
||||
size={5}
|
||||
className={cn('absolute bottom-0 right-0 fill-current', statusColor)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-6 flex-1 min-w-0 flex-col justify-center self-center text-left">
|
||||
{isRenaming ? (
|
||||
<SessionInlineRenameInput
|
||||
initialName={renameValue}
|
||||
onCommit={onSubmitRename}
|
||||
onCancel={onCancelRename}
|
||||
className="h-5 text-xs leading-none"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={cn('truncate text-xs leading-none', isSelected ? 'font-semibold' : 'font-medium')}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStartRename(session.id);
|
||||
}}
|
||||
>
|
||||
{session.customName || session.hostLabel}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[10px] leading-none" style={{ color: mutedFg }}>
|
||||
{session.username}@{session.hostname}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[10px] leading-none" style={{ color: mutedFg }}>
|
||||
{session.username}@{session.hostname}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<SessionTabContextMenuContent
|
||||
sessionId={session.id}
|
||||
onCloseSession={onCloseSession}
|
||||
onCopySession={onCopySession}
|
||||
onCopySessionToNewWindow={onCopySessionToNewWindow}
|
||||
onDetachSession={onDetachSessionFromWorkspace}
|
||||
onRenameSession={onStartRename}
|
||||
t={t}
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
}, (prev, next) => (
|
||||
prev.session === next.session
|
||||
&& prev.host === next.host
|
||||
&& prev.isSelected === next.isSelected
|
||||
&& prev.isRenaming === next.isRenaming
|
||||
&& prev.renameValue === next.renameValue
|
||||
&& prev.isDragging === next.isDragging
|
||||
&& prev.dropPosition === next.dropPosition
|
||||
&& prev.theme === next.theme
|
||||
&& prev.onSelect === next.onSelect
|
||||
&& prev.onStartRename === next.onStartRename
|
||||
&& prev.onSubmitRename === next.onSubmitRename
|
||||
&& prev.onCancelRename === next.onCancelRename
|
||||
&& prev.onCloseSession === next.onCloseSession
|
||||
&& prev.onCopySession === next.onCopySession
|
||||
&& prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow
|
||||
&& prev.onDetachSessionFromWorkspace === next.onDetachSessionFromWorkspace
|
||||
&& prev.onDragStart === next.onDragStart
|
||||
&& prev.onDragOver === next.onDragOver
|
||||
&& prev.onDrop === next.onDrop
|
||||
&& prev.onDragEnd === next.onDragEnd
|
||||
&& prev.t === next.t
|
||||
));
|
||||
WorkspaceFocusSessionRow.displayName = 'WorkspaceFocusSessionRow';
|
||||
|
||||
@@ -165,8 +234,13 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
||||
focusedSessionId,
|
||||
onReorderWorkspaceSessions,
|
||||
onRequestAddToWorkspace,
|
||||
onCloseSession,
|
||||
onCopySession,
|
||||
onCopySessionToNewWindow,
|
||||
onDetachSessionFromWorkspace,
|
||||
onSetWorkspaceFocusedSession,
|
||||
onToggleWorkspaceViewMode,
|
||||
onSubmitSessionRename,
|
||||
resolvedPreviewTheme,
|
||||
sessionHostsMap,
|
||||
sessions,
|
||||
@@ -182,6 +256,9 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH, 224, { min: 160, max: 480 },
|
||||
);
|
||||
|
||||
const [sidebarRenameSessionId, setSidebarRenameSessionId] = useState<string | null>(null);
|
||||
const [sidebarRenameValue, setSidebarRenameValue] = useState('');
|
||||
|
||||
const theme = useMemo<FocusSidebarTheme>(() => {
|
||||
const termBg = resolvedPreviewTheme.colors.background;
|
||||
const termFg = resolvedPreviewTheme.colors.foreground;
|
||||
@@ -208,7 +285,8 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
||||
const term = focusSidebarSearch.trim().toLowerCase();
|
||||
if (!term) return workspaceSessions;
|
||||
return workspaceSessions.filter((session) => (
|
||||
session.hostLabel?.toLowerCase().includes(term)
|
||||
session.customName?.toLowerCase().includes(term)
|
||||
|| session.hostLabel?.toLowerCase().includes(term)
|
||||
|| session.hostname?.toLowerCase().includes(term)
|
||||
|| session.username?.toLowerCase().includes(term)
|
||||
));
|
||||
@@ -349,6 +427,25 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
||||
onSetWorkspaceFocusedSession?.(activeWorkspace.id, sessionId);
|
||||
}, [activeWorkspace.id, onSetWorkspaceFocusedSession]);
|
||||
|
||||
const handleLocalStartRename = useCallback((sessionId: string) => {
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (!session) return;
|
||||
setSidebarRenameSessionId(sessionId);
|
||||
setSidebarRenameValue(session.customName || session.hostLabel || '');
|
||||
}, [sessions]);
|
||||
|
||||
const handleLocalSubmitRename = useCallback((name: string) => {
|
||||
if (!sidebarRenameSessionId) return;
|
||||
onSubmitSessionRename(sidebarRenameSessionId, name);
|
||||
setSidebarRenameSessionId(null);
|
||||
setSidebarRenameValue('');
|
||||
}, [sidebarRenameSessionId, onSubmitSessionRename]);
|
||||
|
||||
const handleLocalCancelRename = useCallback(() => {
|
||||
setSidebarRenameSessionId(null);
|
||||
setSidebarRenameValue('');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 flex flex-col relative"
|
||||
@@ -426,6 +523,15 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
||||
session={session}
|
||||
host={sessionHostsMap.get(session.id)}
|
||||
isSelected={session.id === focusedSessionId}
|
||||
isRenaming={sidebarRenameSessionId === session.id}
|
||||
renameValue={sidebarRenameValue}
|
||||
onStartRename={handleLocalStartRename}
|
||||
onSubmitRename={handleLocalSubmitRename}
|
||||
onCancelRename={handleLocalCancelRename}
|
||||
onCloseSession={onCloseSession}
|
||||
onCopySession={onCopySession}
|
||||
onCopySessionToNewWindow={onCopySessionToNewWindow}
|
||||
onDetachSessionFromWorkspace={onDetachSessionFromWorkspace}
|
||||
isDragging={focusSidebarDragSessionId === session.id}
|
||||
dropPosition={
|
||||
focusSidebarDropIndicator?.sessionId === session.id
|
||||
@@ -438,6 +544,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
||||
onDragOver={handleFocusSidebarDragOver}
|
||||
onDrop={handleFocusSidebarDrop}
|
||||
onDragEnd={handleFocusSidebarDragEnd}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -451,6 +558,11 @@ function terminalFocusSidebarPropsEqual(
|
||||
next: TerminalFocusSidebarProps,
|
||||
): boolean {
|
||||
if (prev.focusedSessionId !== next.focusedSessionId) return false;
|
||||
if (prev.onSubmitSessionRename !== next.onSubmitSessionRename) return false;
|
||||
if (prev.onCloseSession !== next.onCloseSession) return false;
|
||||
if (prev.onCopySession !== next.onCopySession) return false;
|
||||
if (prev.onCopySessionToNewWindow !== next.onCopySessionToNewWindow) return false;
|
||||
if (prev.onDetachSessionFromWorkspace !== next.onDetachSessionFromWorkspace) return false;
|
||||
if (prev.resolvedPreviewTheme !== next.resolvedPreviewTheme) return false;
|
||||
if (prev.sessionHostsMap !== next.sessionHostsMap) return false;
|
||||
if (prev.sessions !== next.sessions) return false;
|
||||
|
||||
@@ -15,8 +15,13 @@ function TerminalLayerFocusSidebarSectionInner({ ctx }: { ctx: FocusSidebarConte
|
||||
focusedSessionId={ctx.focusedSessionId}
|
||||
onReorderWorkspaceSessions={ctx.onReorderWorkspaceSessions}
|
||||
onRequestAddToWorkspace={ctx.onRequestAddToWorkspace}
|
||||
onCloseSession={ctx.handleCloseSession}
|
||||
onCopySession={ctx.onCopySession}
|
||||
onCopySessionToNewWindow={ctx.onCopySessionToNewWindow}
|
||||
onDetachSessionFromWorkspace={ctx.onRemoveSessionFromWorkspace}
|
||||
onSetWorkspaceFocusedSession={ctx.onSetWorkspaceFocusedSession}
|
||||
onToggleWorkspaceViewMode={ctx.onToggleWorkspaceViewMode}
|
||||
onSubmitSessionRename={ctx.onSubmitSessionRename}
|
||||
resolvedPreviewTheme={ctx.resolvedPreviewTheme}
|
||||
sessionHostsMap={ctx.sessionHostsMap}
|
||||
sessions={ctx.sessions}
|
||||
|
||||
@@ -4,9 +4,10 @@ import { activeTabStore } from '../../application/state/activeTabStore';
|
||||
import { useTerminalLayoutSuppressActive } from '../../application/state/terminalLayoutSuppressStore';
|
||||
import type { TerminalSessionExitEvent } from '../../application/state/resolveTerminalSessionExitIntent';
|
||||
import { createTerminalSelectionAttachment } from '../../application/state/terminalSelectionAttachment';
|
||||
import { getTopTabInsertionTarget, isPointInsideRect, WORKSPACE_SESSION_DRAG_TYPE } from '../../application/state/terminalDragData';
|
||||
import { useAIState } from '../../application/state/useAIState';
|
||||
import { useStoredBoolean } from '../../application/state/useStoredBoolean';
|
||||
import { SplitDirection } from '../../domain/workspace';
|
||||
import { collectSessionIds, SplitDirection } from '../../domain/workspace';
|
||||
import { KeyBinding, TerminalSettings } from '../../domain/models';
|
||||
import { STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION } from '../../infrastructure/config/storageKeys';
|
||||
import { cn } from '../../lib/utils';
|
||||
@@ -501,6 +502,13 @@ export interface TerminalLayerProps {
|
||||
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
|
||||
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
|
||||
onReorderWorkspaceSessions?: (workspaceId: string, draggedSessionId: string, targetSessionId: string, position: 'before' | 'after') => void;
|
||||
onReorderTabs?: (draggedId: string, targetId: string, position: 'before' | 'after', additionalTabIds?: readonly string[]) => void;
|
||||
onCopySession?: (sessionId: string) => void;
|
||||
onCopySessionToNewWindow?: (sessionId: string) => void;
|
||||
onRemoveSessionFromWorkspace?: (
|
||||
sessionId: string,
|
||||
tabInsertionTarget?: { tabId: string; position: 'before' | 'after'; additionalTabIds?: readonly string[] },
|
||||
) => void;
|
||||
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
|
||||
onConnectToHost: (host: Host) => void;
|
||||
onCreateLocalTerminal?: () => void;
|
||||
@@ -530,6 +538,9 @@ export interface TerminalLayerProps {
|
||||
showHostTreeSidebar?: boolean;
|
||||
toggleScriptsSidePanelRef?: React.MutableRefObject<(() => void) | null>;
|
||||
toggleSidePanelRef?: React.MutableRefObject<(() => void) | null>;
|
||||
// Session rename
|
||||
onStartSessionRename?: (sessionId: string) => void;
|
||||
onSubmitSessionRename?: (sessionId?: string, name?: string) => void;
|
||||
}
|
||||
|
||||
interface TerminalPaneProps {
|
||||
@@ -597,6 +608,14 @@ interface TerminalPaneProps {
|
||||
) => void;
|
||||
onAddSelectionToAI?: (sessionId: string, selection: string) => void;
|
||||
showSelectionAIAction: boolean;
|
||||
onStartSessionRename?: (sessionId: string) => void;
|
||||
onRemoveSessionFromWorkspace?: (
|
||||
sessionId: string,
|
||||
tabInsertionTarget?: { tabId: string; position: 'before' | 'after'; additionalTabIds?: readonly string[] },
|
||||
) => void;
|
||||
onReorderTabs?: (draggedId: string, targetId: string, position: 'before' | 'after', additionalTabIds?: readonly string[]) => void;
|
||||
onStartSessionDrag?: (sessionId: string) => void;
|
||||
onEndSessionDrag?: () => void;
|
||||
}
|
||||
|
||||
const getPaneThemePreviewId = (props: TerminalPaneProps): string | null => (
|
||||
@@ -684,7 +703,12 @@ const terminalPanePropsAreEqual = (
|
||||
prev.onToggleWorkspaceComposeBar === next.onToggleWorkspaceComposeBar &&
|
||||
prev.onSnippetExecutorChange === next.onSnippetExecutorChange &&
|
||||
prev.onAddSelectionToAI === next.onAddSelectionToAI &&
|
||||
prev.showSelectionAIAction === next.showSelectionAIAction
|
||||
prev.showSelectionAIAction === next.showSelectionAIAction &&
|
||||
prev.onStartSessionRename === next.onStartSessionRename &&
|
||||
prev.onRemoveSessionFromWorkspace === next.onRemoveSessionFromWorkspace &&
|
||||
prev.onReorderTabs === next.onReorderTabs &&
|
||||
prev.onStartSessionDrag === next.onStartSessionDrag &&
|
||||
prev.onEndSessionDrag === next.onEndSessionDrag
|
||||
);
|
||||
|
||||
const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
@@ -743,6 +767,11 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
onSnippetExecutorChange,
|
||||
onAddSelectionToAI,
|
||||
showSelectionAIAction,
|
||||
onStartSessionRename,
|
||||
onRemoveSessionFromWorkspace,
|
||||
onReorderTabs,
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
}) => {
|
||||
const layoutSuppressActive = useTerminalLayoutSuppressActive();
|
||||
const deferPaneLayoutUpdate = isResizing || layoutSuppressActive;
|
||||
@@ -855,6 +884,192 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
}
|
||||
onOpenSystem?.();
|
||||
}, [activeWorkspaceId, isFocusMode, onOpenSystem, onSetWorkspaceFocusedSession, session.id]);
|
||||
const handleRename = useCallback(() => {
|
||||
onStartSessionRename?.(session.id);
|
||||
}, [onStartSessionRename, session.id]);
|
||||
const handleDetach = useCallback(() => {
|
||||
onRemoveSessionFromWorkspace?.(session.id);
|
||||
}, [onRemoveSessionFromWorkspace, session.id]);
|
||||
const handleDetachDragStart = useCallback((e: React.DragEvent) => {
|
||||
if (!inActiveWorkspace) return;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData(WORKSPACE_SESSION_DRAG_TYPE, session.id);
|
||||
e.dataTransfer.setData('session-id', session.id);
|
||||
e.dataTransfer.setData('text/plain', session.id);
|
||||
onStartSessionDrag?.(session.id);
|
||||
}, [inActiveWorkspace, onStartSessionDrag, session.id]);
|
||||
const handleDetachDragEnd = useCallback(() => {
|
||||
onEndSessionDrag?.();
|
||||
}, [onEndSessionDrag]);
|
||||
const handleDetachPointerDown = useCallback((e: React.PointerEvent<HTMLElement>) => {
|
||||
if (!inActiveWorkspace || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const startPoint = { clientX: e.clientX, clientY: e.clientY };
|
||||
const dragLabel = session.customName || session.hostLabel;
|
||||
let dragStarted = false;
|
||||
let ghostEl: HTMLDivElement | null = null;
|
||||
let insertEl: HTMLDivElement | null = null;
|
||||
|
||||
const ensureDragElements = () => {
|
||||
if (!ghostEl) {
|
||||
ghostEl = document.createElement('div');
|
||||
ghostEl.textContent = dragLabel;
|
||||
ghostEl.style.position = 'fixed';
|
||||
ghostEl.style.left = '0';
|
||||
ghostEl.style.top = '0';
|
||||
ghostEl.style.zIndex = '2147483647';
|
||||
ghostEl.style.pointerEvents = 'none';
|
||||
ghostEl.style.maxWidth = '220px';
|
||||
ghostEl.style.padding = '5px 10px';
|
||||
ghostEl.style.borderRadius = '7px';
|
||||
ghostEl.style.border = '1px solid color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 60%, transparent)';
|
||||
ghostEl.style.background = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 90%, transparent)';
|
||||
ghostEl.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
ghostEl.style.boxShadow = '0 12px 28px rgba(0, 0, 0, 0.28)';
|
||||
ghostEl.style.fontSize = '12px';
|
||||
ghostEl.style.fontWeight = '600';
|
||||
ghostEl.style.whiteSpace = 'nowrap';
|
||||
ghostEl.style.overflow = 'hidden';
|
||||
ghostEl.style.textOverflow = 'ellipsis';
|
||||
document.body.appendChild(ghostEl);
|
||||
}
|
||||
|
||||
if (!insertEl) {
|
||||
insertEl = document.createElement('div');
|
||||
insertEl.style.position = 'fixed';
|
||||
insertEl.style.zIndex = '2147483646';
|
||||
insertEl.style.pointerEvents = 'none';
|
||||
insertEl.style.width = '2px';
|
||||
insertEl.style.borderRadius = '999px';
|
||||
insertEl.style.background = 'var(--top-tabs-accent, hsl(var(--accent)))';
|
||||
insertEl.style.boxShadow = '0 0 10px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 70%, transparent)';
|
||||
insertEl.style.display = 'none';
|
||||
document.body.appendChild(insertEl);
|
||||
}
|
||||
};
|
||||
|
||||
const removeDragElements = () => {
|
||||
ghostEl?.remove();
|
||||
insertEl?.remove();
|
||||
ghostEl = null;
|
||||
insertEl = null;
|
||||
};
|
||||
|
||||
const updateDragElements = (event: PointerEvent) => {
|
||||
ensureDragElements();
|
||||
if (ghostEl) {
|
||||
ghostEl.style.transform = `translate(${event.clientX + 12}px, ${event.clientY + 10}px)`;
|
||||
}
|
||||
|
||||
const topTabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
const insertionTarget = getTopTabInsertionTarget(event, topTabsRoot);
|
||||
if (!topTabsRoot || !insertionTarget || !insertEl) {
|
||||
if (insertEl) insertEl.style.display = 'none';
|
||||
return insertionTarget;
|
||||
}
|
||||
|
||||
const targetTab = Array.from(topTabsRoot.querySelectorAll<HTMLElement>('[data-tab-id]'))
|
||||
.find((tab) => tab.dataset.tabId === insertionTarget.tabId);
|
||||
if (!targetTab) {
|
||||
insertEl.style.display = 'none';
|
||||
return insertionTarget;
|
||||
}
|
||||
|
||||
const targetRect = targetTab.getBoundingClientRect();
|
||||
const rootRect = topTabsRoot.getBoundingClientRect();
|
||||
const lineX = insertionTarget.position === 'before' ? targetRect.left : targetRect.right;
|
||||
insertEl.style.display = 'block';
|
||||
insertEl.style.left = `${lineX - 1}px`;
|
||||
insertEl.style.top = `${Math.max(rootRect.top + 5, targetRect.top + 3)}px`;
|
||||
insertEl.style.height = `${Math.max(18, Math.min(rootRect.bottom - rootRect.top - 8, targetRect.height - 4))}px`;
|
||||
return insertionTarget;
|
||||
};
|
||||
|
||||
const resolveStableInsertionTarget = (insertionTarget: ReturnType<typeof getTopTabInsertionTarget>) => {
|
||||
if (!insertionTarget || insertionTarget.tabId !== session.workspaceId) return insertionTarget;
|
||||
const sourceWorkspace = session.workspaceId ? workspaceById.get(session.workspaceId) : undefined;
|
||||
if (!sourceWorkspace) return insertionTarget;
|
||||
const remainingSessionIds = collectSessionIds(sourceWorkspace.root)
|
||||
.filter((candidateId) => candidateId !== session.id);
|
||||
if (remainingSessionIds.length !== 1) return insertionTarget;
|
||||
return {
|
||||
tabId: remainingSessionIds[0],
|
||||
position: insertionTarget.position,
|
||||
};
|
||||
};
|
||||
|
||||
const startDragIfNeeded = (event: PointerEvent) => {
|
||||
if (dragStarted) return;
|
||||
const dx = event.clientX - startPoint.clientX;
|
||||
const dy = event.clientY - startPoint.clientY;
|
||||
if (Math.hypot(dx, dy) < 4) return;
|
||||
dragStarted = true;
|
||||
onStartSessionDrag?.(session.id);
|
||||
updateDragElements(event);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
document.removeEventListener('pointermove', handlePointerMove, true);
|
||||
document.removeEventListener('pointerup', handlePointerUp, true);
|
||||
document.removeEventListener('pointercancel', handlePointerCancel, true);
|
||||
removeDragElements();
|
||||
if (dragStarted) onEndSessionDrag?.();
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
startDragIfNeeded(event);
|
||||
if (dragStarted) updateDragElements(event);
|
||||
};
|
||||
|
||||
const handlePointerCancel = () => {
|
||||
cleanup();
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
startDragIfNeeded(event);
|
||||
const topTabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
const insertionTarget = dragStarted ? updateDragElements(event) : null;
|
||||
const shouldDetach = dragStarted && !!topTabsRoot && isPointInsideRect(event, topTabsRoot.getBoundingClientRect());
|
||||
cleanup();
|
||||
if (shouldDetach) {
|
||||
const stableInsertionTarget = resolveStableInsertionTarget(insertionTarget);
|
||||
if (onRemoveSessionFromWorkspace) {
|
||||
onRemoveSessionFromWorkspace(
|
||||
session.id,
|
||||
stableInsertionTarget
|
||||
? {
|
||||
tabId: stableInsertionTarget.tabId,
|
||||
position: stableInsertionTarget.position,
|
||||
additionalTabIds: [session.id, stableInsertionTarget.tabId],
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
} else if (stableInsertionTarget) {
|
||||
onReorderTabs?.(session.id, stableInsertionTarget.tabId, stableInsertionTarget.position, [
|
||||
session.id,
|
||||
stableInsertionTarget.tabId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointermove', handlePointerMove, true);
|
||||
document.addEventListener('pointerup', handlePointerUp, true);
|
||||
document.addEventListener('pointercancel', handlePointerCancel, true);
|
||||
}, [
|
||||
inActiveWorkspace,
|
||||
onEndSessionDrag,
|
||||
onRemoveSessionFromWorkspace,
|
||||
onReorderTabs,
|
||||
onStartSessionDrag,
|
||||
session.customName,
|
||||
session.hostLabel,
|
||||
session.id,
|
||||
session.workspaceId,
|
||||
workspaceById,
|
||||
]);
|
||||
const handleTerminalFontSizeChange = useCallback((nextFontSize: number) => {
|
||||
onTerminalFontSizeChange?.(session.id, nextFontSize);
|
||||
}, [onTerminalFontSizeChange, session.id]);
|
||||
@@ -931,8 +1146,16 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
sessionLog={sessionLog}
|
||||
sshDebugLogEnabled={sshDebugLogEnabled}
|
||||
sudoAutofillPassword={sudoAutofillPassword}
|
||||
sessionDisplayName={session.customName || session.hostLabel}
|
||||
showSelectionAIAction={showSelectionAIAction}
|
||||
onAddSelectionToAI={onAddSelectionToAI}
|
||||
onRename={handleRename}
|
||||
onDetach={inActiveWorkspace ? handleDetach : undefined}
|
||||
onStartSessionDrag={inActiveWorkspace ? onStartSessionDrag : undefined}
|
||||
onEndSessionDrag={inActiveWorkspace ? onEndSessionDrag : undefined}
|
||||
onDetachPointerDown={inActiveWorkspace ? handleDetachPointerDown : undefined}
|
||||
onDetachDragStart={inActiveWorkspace ? handleDetachDragStart : undefined}
|
||||
onDetachDragEnd={inActiveWorkspace ? handleDetachDragEnd : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -998,6 +1221,11 @@ interface TerminalPanesHostProps {
|
||||
executor: SnippetExecutor | null,
|
||||
) => void;
|
||||
onAddSelectionToAI?: (sessionId: string, selection: string) => void;
|
||||
onStartSessionRename?: (sessionId: string) => void;
|
||||
onRemoveSessionFromWorkspace?: TerminalPaneProps['onRemoveSessionFromWorkspace'];
|
||||
onReorderTabs?: (draggedId: string, targetId: string, position: 'before' | 'after', additionalTabIds?: readonly string[]) => void;
|
||||
onStartSessionDrag?: (sessionId: string) => void;
|
||||
onEndSessionDrag?: () => void;
|
||||
}
|
||||
|
||||
const terminalPanesHostPropsAreEqual = (
|
||||
@@ -1057,6 +1285,11 @@ const terminalPanesHostPropsAreEqual = (
|
||||
if (prev.onToggleWorkspaceComposeBar !== next.onToggleWorkspaceComposeBar) return false;
|
||||
if (prev.onSnippetExecutorChange !== next.onSnippetExecutorChange) return false;
|
||||
if (prev.onAddSelectionToAI !== next.onAddSelectionToAI) return false;
|
||||
if (prev.onStartSessionRename !== next.onStartSessionRename) return false;
|
||||
if (prev.onRemoveSessionFromWorkspace !== next.onRemoveSessionFromWorkspace) return false;
|
||||
if (prev.onReorderTabs !== next.onReorderTabs) return false;
|
||||
if (prev.onStartSessionDrag !== next.onStartSessionDrag) return false;
|
||||
if (prev.onEndSessionDrag !== next.onEndSessionDrag) return false;
|
||||
|
||||
if (prev.workspaceRectsById === next.workspaceRectsById) return true;
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
systemWarmupSessionIds,
|
||||
systemBackend,
|
||||
systemWarmupSessionIds.length > 0,
|
||||
(s.terminalSettings?.systemManagerProcessRefreshInterval ?? 3) * 1000,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -391,8 +392,16 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
onCreateLocalTerminal: s.onCreateLocalTerminal,
|
||||
onHotkeyAction: s.onHotkeyAction,
|
||||
onReorderWorkspaceSessions: s.onReorderWorkspaceSessions,
|
||||
onReorderTabs: s.onReorderTabs,
|
||||
onCopySession: s.onCopySession,
|
||||
onCopySessionToNewWindow: s.onCopySessionToNewWindow,
|
||||
onRequestAddToWorkspace: s.onRequestAddToWorkspace,
|
||||
onSetWorkspaceFocusedSession: s.onSetWorkspaceFocusedSession,
|
||||
onStartSessionRename: s.onStartSessionRename,
|
||||
onSubmitSessionRename: s.onSubmitSessionRename,
|
||||
onRemoveSessionFromWorkspace: s.onRemoveSessionFromWorkspace,
|
||||
onStartSessionDrag: s.onStartSessionDrag,
|
||||
onEndSessionDrag: s.onEndSessionDrag,
|
||||
onSplitSession: s.onSplitSession,
|
||||
onToggleWorkspaceViewMode: s.onToggleWorkspaceViewMode,
|
||||
Palette: s.Palette,
|
||||
|
||||
@@ -83,6 +83,11 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
TerminalComposeBar,
|
||||
Array,
|
||||
cn,
|
||||
onStartSessionRename,
|
||||
onRemoveSessionFromWorkspace,
|
||||
onReorderTabs,
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
} = ctx;
|
||||
|
||||
return (
|
||||
@@ -180,6 +185,11 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
onToggleWorkspaceComposeBar={handleToggleWorkspaceComposeBar}
|
||||
onSnippetExecutorChange={handleSnippetExecutorChange}
|
||||
onAddSelectionToAI={handleAddSelectionToAI}
|
||||
onStartSessionRename={onStartSessionRename}
|
||||
onRemoveSessionFromWorkspace={onRemoveSessionFromWorkspace}
|
||||
onReorderTabs={onReorderTabs}
|
||||
onStartSessionDrag={onStartSessionDrag}
|
||||
onEndSessionDrag={onEndSessionDrag}
|
||||
/>
|
||||
{!isFocusMode && activeResizers.map((handle: any) => {
|
||||
const isVertical = handle.direction === 'vertical';
|
||||
|
||||
@@ -137,6 +137,9 @@ export type TerminalLayerStableSnapshot = {
|
||||
onConnectToHost: TerminalLayerProps['onConnectToHost'];
|
||||
onCreateLocalTerminal: TerminalLayerProps['onCreateLocalTerminal'];
|
||||
onReorderWorkspaceSessions: TerminalLayerProps['onReorderWorkspaceSessions'];
|
||||
onReorderTabs: TerminalLayerProps['onReorderTabs'];
|
||||
onCopySession: TerminalLayerProps['onCopySession'];
|
||||
onCopySessionToNewWindow: TerminalLayerProps['onCopySessionToNewWindow'];
|
||||
onRequestAddToWorkspace: TerminalLayerProps['onRequestAddToWorkspace'];
|
||||
onSetWorkspaceFocusedSession: TerminalLayerProps['onSetWorkspaceFocusedSession'];
|
||||
onToggleWorkspaceViewMode: TerminalLayerProps['onToggleWorkspaceViewMode'];
|
||||
|
||||
@@ -284,6 +284,11 @@ const WORKSPACE_CTX_KEYS = [
|
||||
'setResizing',
|
||||
'Array',
|
||||
'cn',
|
||||
'onStartSessionRename',
|
||||
'onRemoveSessionFromWorkspace',
|
||||
'onReorderTabs',
|
||||
'onStartSessionDrag',
|
||||
'onEndSessionDrag',
|
||||
] as const;
|
||||
|
||||
export function terminalLayerSidePanelCtxEqual(prev: Ctx, next: Ctx): boolean {
|
||||
@@ -337,6 +342,11 @@ export function terminalLayerFocusSidebarPropsEqual(prev: Ctx, next: Ctx): boole
|
||||
&& eq(prev, next, 't')
|
||||
&& eq(prev, next, 'onReorderWorkspaceSessions')
|
||||
&& eq(prev, next, 'onRequestAddToWorkspace')
|
||||
&& eq(prev, next, 'handleCloseSession')
|
||||
&& eq(prev, next, 'onCopySession')
|
||||
&& eq(prev, next, 'onCopySessionToNewWindow')
|
||||
&& eq(prev, next, 'onRemoveSessionFromWorkspace')
|
||||
&& eq(prev, next, 'onSetWorkspaceFocusedSession')
|
||||
&& eq(prev, next, 'onToggleWorkspaceViewMode');
|
||||
&& eq(prev, next, 'onToggleWorkspaceViewMode')
|
||||
&& eq(prev, next, 'onSubmitSessionRename');
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export const terminalLayerAreEqual = (
|
||||
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
|
||||
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
||||
prev.onReorderWorkspaceSessions === next.onReorderWorkspaceSessions &&
|
||||
prev.onReorderTabs === next.onReorderTabs &&
|
||||
prev.onSplitSession === next.onSplitSession &&
|
||||
prev.onConnectToHost === next.onConnectToHost &&
|
||||
prev.onCreateLocalTerminal === next.onCreateLocalTerminal &&
|
||||
|
||||
55
components/top-tabs/SessionTabContextMenuContent.tsx
Normal file
55
components/top-tabs/SessionTabContextMenuContent.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { ContextMenuContent, ContextMenuItem } from '../ui/context-menu';
|
||||
|
||||
type TranslateFn = ReturnType<typeof useI18n>['t'];
|
||||
|
||||
interface SessionTabContextMenuContentProps {
|
||||
sessionId: string;
|
||||
onCloseSession: (sessionId: string) => void;
|
||||
onCopySession?: (sessionId: string) => void;
|
||||
onCopySessionToNewWindow?: (sessionId: string) => void;
|
||||
onDetachSession?: (sessionId: string) => void;
|
||||
onRenameSession: (sessionId: string) => void;
|
||||
renderBulkCloseItems?: (anchorId: string) => React.ReactNode;
|
||||
t: TranslateFn;
|
||||
}
|
||||
|
||||
export function SessionTabContextMenuContent({
|
||||
sessionId,
|
||||
onCloseSession,
|
||||
onCopySession,
|
||||
onCopySessionToNewWindow,
|
||||
onDetachSession,
|
||||
onRenameSession,
|
||||
renderBulkCloseItems,
|
||||
t,
|
||||
}: SessionTabContextMenuContentProps) {
|
||||
return (
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onRenameSession(sessionId)}>
|
||||
{t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
{onCopySession && (
|
||||
<ContextMenuItem onClick={() => onCopySession(sessionId)}>
|
||||
{t('tabs.copyTab')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onCopySessionToNewWindow && (
|
||||
<ContextMenuItem onClick={() => onCopySessionToNewWindow(sessionId)}>
|
||||
{t('tabs.copyTabToNewWindow')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onDetachSession && (
|
||||
<ContextMenuItem onClick={() => onDetachSession(sessionId)}>
|
||||
{t('terminal.menu.detach')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(sessionId)}>
|
||||
{t('common.close')}
|
||||
</ContextMenuItem>
|
||||
{renderBulkCloseItems?.(sessionId)}
|
||||
</ContextMenuContent>
|
||||
);
|
||||
}
|
||||
@@ -11,8 +11,9 @@ import { Host, TerminalSession, Workspace } from '../../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from '../DistroAvatar';
|
||||
import { getShellIconPath, isMonochromeShellIcon } from '../../lib/useDiscoveredShells';
|
||||
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../../lib/tabInteractions';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '../ui/context-menu';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '../ui/context-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { SessionTabContextMenuContent } from './SessionTabContextMenuContent';
|
||||
|
||||
// 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;
|
||||
@@ -555,7 +556,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
)}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<SessionTabIcon host={host} isActive={isActive} protocol={session.protocol} shellIcon={session.localShellIcon} />
|
||||
<span className="truncate">{session.hostLabel}</span>
|
||||
<span className="truncate">{session.customName || session.hostLabel}</span>
|
||||
<div className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -567,21 +568,15 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
</button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onRenameSession(session.id)}>
|
||||
{t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onCopySession(session.id)}>
|
||||
{t('tabs.copyTab')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onCopySessionToNewWindow(session.id)}>
|
||||
{t('tabs.copyTabToNewWindow')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(session.id)}>
|
||||
{t('common.close')}
|
||||
</ContextMenuItem>
|
||||
{renderBulkCloseItems(session.id)}
|
||||
</ContextMenuContent>
|
||||
<SessionTabContextMenuContent
|
||||
sessionId={session.id}
|
||||
onCloseSession={onCloseSession}
|
||||
onCopySession={onCopySession}
|
||||
onCopySessionToNewWindow={onCopySessionToNewWindow}
|
||||
onRenameSession={onRenameSession}
|
||||
renderBulkCloseItems={renderBulkCloseItems}
|
||||
t={t}
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
});
|
||||
@@ -603,6 +598,8 @@ interface WorkspaceTopTabProps {
|
||||
onTabDrop: (e: React.DragEvent, targetTabId: string) => void;
|
||||
onRenameWorkspace: (workspaceId: string) => void;
|
||||
onCloseWorkspace: (workspaceId: string) => void;
|
||||
onDetachSessionFromWorkspace?: (workspaceId: string, sessionId: string) => void;
|
||||
workspaceSessionLabels?: Record<string, string>;
|
||||
renderBulkCloseItems: RenderBulkCloseItems;
|
||||
t: TranslateFn;
|
||||
tabAnimationClass?: string;
|
||||
@@ -624,6 +621,8 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
onTabDrop,
|
||||
onRenameWorkspace,
|
||||
onCloseWorkspace,
|
||||
onDetachSessionFromWorkspace,
|
||||
workspaceSessionLabels,
|
||||
renderBulkCloseItems,
|
||||
t,
|
||||
tabAnimationClass,
|
||||
@@ -715,6 +714,17 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
<ContextMenuItem onClick={() => onRenameWorkspace(workspace.id)}>
|
||||
{t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
{onDetachSessionFromWorkspace && workspaceSessionLabels && Object.entries(workspaceSessionLabels).map(([sessionId, label]) => (
|
||||
<ContextMenuItem
|
||||
key={sessionId}
|
||||
onClick={() => onDetachSessionFromWorkspace(workspace.id, sessionId)}
|
||||
>
|
||||
{t('terminal.menu.detachSession', { name: label })}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
{onDetachSessionFromWorkspace && workspaceSessionLabels && Object.keys(workspaceSessionLabels).length > 0 && (
|
||||
<ContextMenuSeparator />
|
||||
)}
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onCloseWorkspace(workspace.id)}>
|
||||
{t('common.close')}
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -243,6 +243,12 @@ test("normalizeDistroId matches Alibaba Cloud Linux PRETTY_NAME/NAME fallback",
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeDistroId maps openEuler before the generic Linux fallback", () => {
|
||||
assert.equal(normalizeDistroId("openeuler"), "openeuler");
|
||||
assert.equal(normalizeDistroId("openEuler"), "openeuler");
|
||||
assert.notEqual(normalizeDistroId("openeuler"), "linux");
|
||||
});
|
||||
|
||||
test("shouldProbeSessionCwd allows the probe on a plain Linux host", () => {
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
|
||||
@@ -47,6 +47,7 @@ export const LINUX_DISTRO_OPTIONS = [
|
||||
'oracle',
|
||||
'kali',
|
||||
'alinux',
|
||||
'openeuler',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -86,6 +87,7 @@ export const normalizeDistroId = (value?: string) => {
|
||||
if (v.includes('almalinux')) return 'almalinux';
|
||||
if (v.includes('oracle')) return 'oracle';
|
||||
if (v.includes('kali')) return 'kali';
|
||||
if (v.includes('openeuler') || v.includes('open euler')) return 'openeuler';
|
||||
// Alibaba Cloud Linux: os-release ID is `alinux` (older branding: Aliyun
|
||||
// Linux / `aliyun`). Must come before the generic `linux` fallback because
|
||||
// 'alinux'.includes('linux') is true and would otherwise resolve to 'linux'.
|
||||
|
||||
@@ -197,6 +197,7 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
|
||||
{ id: 'next-tab', action: 'nextTab', label: 'Next Tab', mac: '⌘ + Shift + ]', pc: 'Ctrl + Tab', category: 'tabs' },
|
||||
{ id: 'prev-tab', action: 'prevTab', label: 'Previous Tab', mac: '⌘ + Shift + [', pc: 'Ctrl + Shift + Tab', category: 'tabs' },
|
||||
{ id: 'close-tab', action: 'closeTab', label: 'Close Tab', mac: '⌘ + W', pc: 'Ctrl + W', category: 'tabs' },
|
||||
{ id: 'close-session', action: 'closeSession', label: 'Close Session Pane', mac: '⌘ + Shift + W', pc: 'Ctrl + Shift + W', category: 'tabs' },
|
||||
{ id: 'new-tab', action: 'newTab', label: 'New Local Tab', mac: '⌘ + T', pc: 'Ctrl + T', category: 'tabs' },
|
||||
|
||||
// Terminal Operations
|
||||
@@ -214,6 +215,7 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
|
||||
{ id: 'move-focus', action: 'moveFocus', label: 'Move focus between Split View panes', mac: '⌘ + ⌥ + arrows', pc: 'Ctrl + Alt + arrows', category: 'navigation' },
|
||||
{ id: 'split-horizontal', action: 'splitHorizontal', label: 'Split Horizontal', mac: '⌘ + D', pc: 'Ctrl + Shift + D', category: 'navigation' },
|
||||
{ id: 'split-vertical', action: 'splitVertical', label: 'Split Vertical', mac: '⌘ + Shift + D', pc: 'Ctrl + Shift + E', category: 'navigation' },
|
||||
{ id: 'toggle-pane-zoom', action: 'togglePaneZoom', label: 'Toggle Pane Zoom', mac: '⌘ + Shift + Enter', pc: 'Ctrl + Shift + Enter', category: 'navigation' },
|
||||
|
||||
// App Features
|
||||
{ id: 'open-hosts', action: 'openHosts', label: 'Open Hosts Page', mac: 'Disabled', pc: 'Disabled', category: 'app' },
|
||||
|
||||
@@ -391,4 +391,6 @@ export interface TerminalSession {
|
||||
// Per-pane font size override (workspace splits only; not persisted to vault hosts).
|
||||
fontSize?: number;
|
||||
fontSizeOverride?: boolean;
|
||||
/** User-assigned display name for this terminal session (overrides hostLabel in UI) */
|
||||
customName?: string;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
37
domain/systemManagerPanelState.test.ts
Normal file
37
domain/systemManagerPanelState.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveCapabilityPanelState } from "./systemManagerPanelState.ts";
|
||||
|
||||
test("keeps unavailable state visible while a known-missing capability is refreshed", () => {
|
||||
assert.equal(
|
||||
resolveCapabilityPanelState({
|
||||
isActive: true,
|
||||
ready: false,
|
||||
capabilitiesKnown: true,
|
||||
}),
|
||||
"unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
test("shows checking only before capabilities are known", () => {
|
||||
assert.equal(
|
||||
resolveCapabilityPanelState({
|
||||
isActive: true,
|
||||
ready: false,
|
||||
capabilitiesKnown: false,
|
||||
}),
|
||||
"checking",
|
||||
);
|
||||
});
|
||||
|
||||
test("hides inactive capability panels", () => {
|
||||
assert.equal(
|
||||
resolveCapabilityPanelState({
|
||||
isActive: false,
|
||||
ready: false,
|
||||
capabilitiesKnown: true,
|
||||
}),
|
||||
"hidden",
|
||||
);
|
||||
});
|
||||
16
domain/systemManagerPanelState.ts
Normal file
16
domain/systemManagerPanelState.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type CapabilityPanelState = "hidden" | "checking" | "unavailable" | "ready";
|
||||
|
||||
export function resolveCapabilityPanelState({
|
||||
isActive,
|
||||
ready,
|
||||
capabilitiesKnown,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
ready: boolean;
|
||||
capabilitiesKnown: boolean;
|
||||
}): CapabilityPanelState {
|
||||
if (!isActive) return "hidden";
|
||||
if (ready) return "ready";
|
||||
if (capabilitiesKnown) return "unavailable";
|
||||
return "checking";
|
||||
}
|
||||
@@ -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,14 +9,16 @@ 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 && printf "%s\\n" __NC_DOCKER__=1',
|
||||
'command -v docker >/dev/null 2>&1 && printf "%s\\n" __NC_DOCKER__=1',
|
||||
"'",
|
||||
].join("");
|
||||
|
||||
const PROCESS_LIST_SCRIPT_POSIX = [
|
||||
"exec sh -c ",
|
||||
"'",
|
||||
"ps -eo pid= -o ppid= -o user= -o stat= -o pcpu= -o pmem= -o rss= -o vsz= -o etime= -o args= 2>/dev/null | head -n 200",
|
||||
// Safety cap: head -n 2000 prevents maxBuffer/timeout on process-dense hosts.
|
||||
// This is NOT a functional limit — monitored processes still show accurate metrics.
|
||||
"ps -eo pid= -o ppid= -o user= -o stat= -o pcpu= -o pmem= -o rss= -o vsz= -o etime= -o args= 2>/dev/null | head -n 2000",
|
||||
"'",
|
||||
].join("");
|
||||
|
||||
@@ -109,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;
|
||||
@@ -134,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 && 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 {
|
||||
@@ -164,8 +166,10 @@ function createSystemManagerBridge(deps) {
|
||||
if (!sessionId) return { success: false, error: "Missing sessionId" };
|
||||
|
||||
if (isLocalSession(sessionId) && process.platform === "win32") {
|
||||
// Safety cap: -First 2000 prevents maxBuffer/timeout on process-dense hosts.
|
||||
// This is NOT a functional limit — monitored processes still show accurate metrics.
|
||||
const result = await execOnLocalMachine(
|
||||
"Get-CimInstance Win32_Process | Sort-Object KernelModeTime -Descending | Select-Object -First 200 ProcessId,ParentProcessId,Name,WorkingSetSize | ConvertTo-Json -Compress",
|
||||
"Get-CimInstance Win32_Process | Sort-Object KernelModeTime -Descending | Select-Object -First 2000 ProcessId,ParentProcessId,Name,WorkingSetSize | ConvertTo-Json -Compress",
|
||||
10000,
|
||||
);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
|
||||
@@ -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.
|
||||
|
||||
20
index.css
20
index.css
@@ -181,6 +181,26 @@
|
||||
transition: width 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.terminal-topbar {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.terminal-title-cluster {
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
@container (max-width: 760px) {
|
||||
.terminal-server-stats {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 420px) {
|
||||
.terminal-title-cluster {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.host-tree-notes-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.28) transparent;
|
||||
|
||||
@@ -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",
|
||||
|
||||
1
public/distro/openeuler.svg
Normal file
1
public/distro/openeuler.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="80 45 93 105" xmlns="http://www.w3.org/2000/svg"><title>openEuler</title><path d="m170.54,69.83l-42.27-24.4c-1.13-.65-2.53-.65-3.66.02l-42.28,24.4c-1.12.65-1.82,1.85-1.83,3.15v48.82c0,1.31.69,2.52,1.83,3.18l42.28,24.41c1.13.65,2.53.65,3.66,0l42.27-24.41c1.14-.65,1.83-1.86,1.83-3.17v-48.83c0-1.31-.7-2.52-1.83-3.17Zm-33.72,49.99c-3.3,4.2-9.89,5.42-14.3,2.66-4.19-2.59-4.52-7.61-1.19-11.18,3.39-3.46,8.57-4.45,13-2.49.65.28,1.26.67,1.8,1.13,2.92,2.54,3.23,6.96.69,9.88Zm7.89-39.11c-2.14,2.21-6.79,3.15-10.14,2.09-1.92-.6-2.94-1.87-3-2.87-.04-.8-1.24-1.53-2.57-1.93-1.45-.34-2.94-.39-4.41-.15-.59.09-1.18.22-1.75.4-1.33.39-2.58,1.02-3.68,1.86-1.02.95-1.62,2.25-1.67,3.64.05.98.95,1.82,2.33,2.34,1.52.51,3.14.62,4.71.34,2.01-.58,4.14-.55,6.13.07,3.36,1.19,4.22,4.22,1.67,6.82-3,2.77-7.31,3.64-11.15,2.23-1.54-.61-2.58-2.07-2.65-3.73-.08-1.09-1.05-1.96-2.38-2.51-1.5-.49-3.09-.62-4.65-.38-.23.02-1.27.23-1.91.4-1.57.35-3.02,1.12-4.19,2.22-.85.87-1.53,1.88-2,3-.2.5-.33,1.02-.4,1.55.02,1.31.8,2.48,2,3,1.55.73,3.28.95,4.96.65,2.14-.66,4.45-.53,6.51.35,3.39,1.65,3.79,5.59.6,8.84-3.38,3.4-9.02,4.5-12.38,2.4-1.68-1.02-2.53-2.98-2.12-4.9,0-.25-.03-.5-.07-.74-.05-.32-.15-.62-.3-.91-.38-.72-.97-1.32-1.68-1.71-1.5-.76-3.2-1-4.85-.68-1.95.67-4.08.53-5.93-.38-2.48-1.55-1.73-4.83,1.45-7.31,1.62-1.25,3.52-2.09,5.53-2.45,1.86-.37,3.61-1.17,5.1-2.34,1.19-.79,2-2.05,2.23-3.46.01-.39-.01-.78-.08-1.17-.14-1.02.16-2.06.83-2.84.82-.8,1.87-1.33,3-1.5.88-.12,1.76-.17,2.64-.16.63-.03,1.25-.12,1.86-.25,1.1-.31,2.12-.85,3-1.58.83-.55,1.43-1.39,1.69-2.36.04-.33.06-.67.06-1-.32-1.75,2.13-3.68,5.47-4.3,1.49-.32,3.04-.26,4.51.15h.12c.98.15,1.83.75,2.3,1.63v.06c.05.13.09.26.1.4.5.96,1.43,1.63,2.5,1.81,1.47.36,3,.41,4.49.13,1.89-.51,3.87-.55,5.77-.11,3.3.85,4.54,3.13,2.4,5.34Zm18.23,5.26c-2.02,2.56-7.23,3.6-11.37,2.28-3.94-1.26-5.14-4.28-3.08-6.43,2.06-2.15,6.67-3.15,10.51-2.15,4,1.04,5.96,3.74,3.94,6.3Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
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