From f5c330232944b3ef974b48dd7c7ce31acd793319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=A4=A7=E7=8C=AB?= <16399091+binaricat@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:30:44 +0800 Subject: [PATCH] feat: terminal rename, closeSession shortcut, and pane zoom (#1459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: auto-poll Docker capabilities while Docker tab is active When the Docker tab is visible and hasDocker is not yet true, poll refreshCapabilities() at the process refresh interval. Stop polling once hasDocker becomes true, or when switching to a different tab. * fix: use resolvedTab instead of activeTab for Docker auto-poll condition The auto-poll useEffect condition used activeTab, which stays stale when Docker becomes unavailable. Changed to resolvedTab which reflects the actual displayed tab. Also updated the dep array. * fix: replace setInterval with setTimeout recursion in Docker tab probe Replace setInterval-based polling with setTimeout recursion in the Docker tab capability probe effect. This ensures the next probe only starts after the previous one finishes, avoiding overlapping probes when SSH timeout exceeds the polling interval. - Add dockerPollTimerRef to track the timeout handle - Use async pollOnce() that awaits refreshCapabilities() before scheduling next - Use cancelled flag in cleanup to prevent scheduling after unmount - Keep same dependency array for correctness * fix: stabilize docker poll timer by using useRef for refreshCapabilities refreshCapabilities() can return a new reference on every render, causing the useEffect to re-run on every render — cleanup cancels the polling timer, then the effect immediately calls pollOnce(), effectively bypassing the configured timeout interval. Fix: store refreshCapabilities in a useRef (refreshRef), use refreshRef.current() inside pollOnce(), and replace refreshCapabilities with refreshRef in the useEffect dependency array. Closes #PR1456 Codex P2 review item. * fix: delay auto-poll first probe by one interval to avoid overlap with tab-switch probe When switching to the Docker tab, two mechanisms were triggering probes: 1. tab-switch effect (line 67-76): immediate probe via refreshCapabilities() 2. auto-poll effect: pollOnce() executing immediately on mount This caused duplicate probes that waste SSH channel resources. Fix: pollOnce() no longer fires on mount. Instead, the effect schedules the first probe with setTimeout(pollOnce, capabilitiesTtlMs), so the first probe happens after one full interval. Subsequent probes continue at interval pace via the setTimeout recursion in pollOnce itself. The tab-switch effect still fires the immediate probe (the correct one), so responsiveness on tab switch is preserved. * fix: reset cancelledRef in effect body to prevent permanent stalling of Docker polling The cancelledRef was set to true in the cleanup function when dependencies changed, but never reset when the effect re-ran. This caused pollOnce to always early-return on subsequent timer ticks, permanently halting Docker capability probing after the first dependency change. * fix(system-manager): replace cancelledRef with closure variables for per-effect cancellation Each effect generation now has its own and closure variables instead of shared / . This prevents stale probes from surviving cleanup when the panel hides and re-shows (Codex P2 review). * fix: wrap refreshCapabilities in try/catch to keep polling on exception If refreshCapabilities throws (instead of returning {success: false}), the await would exit pollOnce without scheduling the next setTimeout, silently killing Docker auto-detection polling. * fix: add in-flight probe guard to prevent tab-switch and auto-poll concurrent probes Add probingRef to track whether a capabilities probe is already in-flight. - Tab-switch effect for Docker branch checks probingRef before starting a new probe - Auto-poll pollOnce checks probingRef at entry and sets/clears it around the actual probe - Tmux branch left unchanged as it has no auto-poll overlap risk * fix: re-schedule next poll timer when probe is in-flight When probingRef.current is true (tab-switch probe still running), pollOnce was returning early without scheduling the next timer, causing auto-poll to stop permanently afterward. Now it schedules the next poll within the interval and returns, so the polling loop keeps running until a slot where no probe is active. * fix: convert comments to ASCII-only English - Line 105: translate Chinese comment to 'probe is in-flight, reschedule for next cycle' - Line 113: replace em dash (U+2014) with ASCII dash * feat: session inline rename, closeSession shortcut, pane zoom * fix: sidebar inline rename with local state * fix: add sessionDisplayName to terminalPropsAreEqual comparator The Terminal component is wrapped with React.memo(…, terminalPropsAreEqual), but the comparator was missing a check for sessionDisplayName. After renaming a session, the pane title bar would show the old name until some other prop changed and triggered a re-render. Add prev.sessionDisplayName === next.sessionDisplayName to the comparator so that display name changes cause the Terminal to re-render immediately. * fix: add onStartSessionRename to TerminalLayerWorkspaceSection ctx destructuring and TerminalPanesHost props * fix: add toggleWorkspaceViewMode to executeHotkeyActionImpl destructuring The togglePaneZoom handler calls toggleWorkspaceViewMode() but it wasn't destructured from getCtx(), causing a ReferenceError at runtime. * fix: restore truncated ctx object in TerminalView render call The TerminalView ctx object literal on line 1265 was truncated to 'showSele...' due to an editing tool truncation bug, causing Parsing error: ',' expected on npm run lint / tsc --noEmit. Restored the missing fields from the base commit: showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem Kept the PR's new additions (isVisible, onRename, sessionDisplayName) intact. * fix: add toggleWorkspaceViewMode to executeHotkeyAction context and add terminal.menu.rename translations - Add toggleWorkspaceViewMode to the context getter in executeHotkeyAction (App.tsx) - Add terminal.menu.rename translation for en (Rename), zh-CN (重命名), ru (Переименовать) * fix: validate focusedSessionId before closing in closeSession hotkey When closeSession hotkey fires, workspace.focusedSessionId may reference a session that was already closed by another trigger (e.g., mouse click on tab close button). Collect alive session IDs from the workspace root and fall back to the first living pane if the stored focusedSessionId is stale. * fix(auto-poll): check useSessionCapabilities probing state in pollOnce When auto-poll timer fires before the initial probe (from useSessionCapabilities) completes, probingRef.current is still false because the initial probe doesn't set it — causing a second overlapping probe. Add check so that any in-flight probe from any path (initial/auto-poll/tab-switch) prevents auto-poll overlap. PR #1459 * fix: address remaining Codex review issues Co-authored-by: Cursor * feat: add detach session from workspace with toolbar button and context menu Co-authored-by: Cursor * fix: use customName in pane header display name for renamed sessions Co-authored-by: Cursor * fix: refine workspace terminal detach interactions * fix: preserve workspace detach tab ordering --------- Co-authored-by: Cursor --- App.tsx | 6 +- application/app/AppHandlers.ts | 45 +++- application/app/AppView.tsx | 9 +- application/app/workTabSurface.test.ts | 24 ++ application/app/workTabSurface.ts | 46 +++- application/i18n/locales/en/terminal.ts | 4 + application/i18n/locales/ru/terminal.ts | 4 + application/i18n/locales/zh-CN/terminal.ts | 3 + application/i18n/locales/zh-CN/vault.ts | 4 + .../state/sessionWorkspaceDetach.test.ts | 123 +++++++++ application/state/sessionWorkspaceDetach.ts | 182 ++++++++++++++ application/state/terminalDragData.ts | 62 +++++ application/state/useGlobalHotkeys.ts | 2 + application/state/useSessionState.ts | 163 +++++++----- components/Terminal.tsx | 14 +- components/TerminalLayer.tsx | 15 ++ components/TopTabs.test.ts | 156 ++++++++++++ components/TopTabs.tsx | 113 ++++++++- .../terminal/SessionInlineRenameInput.tsx | 81 ++++++ components/terminal/TerminalContextMenu.tsx | 26 ++ components/terminal/TerminalServerStats.tsx | 19 +- components/terminal/TerminalView.tsx | 49 +++- components/terminal/terminalHelpers.ts | 20 ++ components/terminal/terminalMemo.ts | 8 + components/terminal/toolbarFocus.test.ts | 15 ++ components/terminal/toolbarFocus.ts | 7 +- .../terminalLayer/TerminalFocusSidebar.tsx | 234 ++++++++++++----- .../TerminalLayerFocusSidebarSection.tsx | 5 + .../terminalLayer/TerminalLayerSupport.tsx | 237 +++++++++++++++++- .../terminalLayer/TerminalLayerTabBridge.tsx | 8 + .../TerminalLayerWorkspaceSection.tsx | 10 + .../terminalLayerStableSnapshot.ts | 3 + .../terminalLayer/terminalLayerViewMemo.ts | 12 +- components/terminalLayerMemo.ts | 1 + .../top-tabs/SessionTabContextMenuContent.tsx | 55 ++++ components/top-tabs/TopTabItems.tsx | 44 ++-- domain/models/keyBindings.ts | 2 + domain/models/terminal.ts | 2 + index.css | 20 ++ 39 files changed, 1655 insertions(+), 178 deletions(-) create mode 100644 application/state/sessionWorkspaceDetach.test.ts create mode 100644 application/state/sessionWorkspaceDetach.ts create mode 100644 application/state/terminalDragData.ts create mode 100644 components/terminal/SessionInlineRenameInput.tsx create mode 100644 components/top-tabs/SessionTabContextMenuContent.tsx diff --git a/App.tsx b/App.tsx index 0f23bffb..1cee37d7 100755 --- a/App.tsx +++ b/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('[role="dialog"][data-state="open"]')); @@ -988,7 +990,7 @@ function App({ settings }: { settings: SettingsState }) { logViews={logViews} t={t} /> - + ); } diff --git a/application/app/AppHandlers.ts b/application/app/AppHandlers.ts index 3c7a8d52..a658024e 100644 --- a/application/app/AppHandlers.ts +++ b/application/app/AppHandlers.ts @@ -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(); diff --git a/application/app/AppView.tsx b/application/app/AppView.tsx index 48a35c10..f505f9f4 100644 --- a/application/app/AppView.tsx +++ b/application/app/AppView.tsx @@ -42,7 +42,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) { handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, - passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, 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, @@ -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} @@ -281,6 +282,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 +311,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 */} diff --git a/application/app/workTabSurface.test.ts b/application/app/workTabSurface.test.ts index 7657d1bc..87f1d7ce 100644 --- a/application/app/workTabSurface.test.ts +++ b/application/app/workTabSurface.test.ts @@ -6,6 +6,7 @@ import { isHostTreeWorkTabSurface, isRootPageTabId, isTerminalContentTabSurface, + reorderWorkTabIds, resolveWorkTabActiveHostId, } from './workTabSurface'; import type { EditorTab } from '../state/editorTabStore'; @@ -18,6 +19,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); diff --git a/application/app/workTabSurface.ts b/application/app/workTabSurface.ts index 1218f6e7..ea274c95 100644 --- a/application/app/workTabSurface.ts +++ b/application/app/workTabSurface.ts @@ -5,6 +5,17 @@ import { import type { EditorTab } from '../state/editorTabStore'; import type { TerminalSession, Workspace } from '../../types'; +function uniqueTabIds(tabIds: readonly string[]): string[] { + const seen = new Set(); + 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 +24,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, diff --git a/application/i18n/locales/en/terminal.ts b/application/i18n/locales/en/terminal.ts index 53ca4e03..3f5a0675 100644 --- a/application/i18n/locales/en/terminal.ts +++ b/application/i18n/locales/en/terminal.ts @@ -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}', diff --git a/application/i18n/locales/ru/terminal.ts b/application/i18n/locales/ru/terminal.ts index 8bf2fcd1..6a2627ae 100644 --- a/application/i18n/locales/ru/terminal.ts +++ b/application/i18n/locales/ru/terminal.ts @@ -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}', diff --git a/application/i18n/locales/zh-CN/terminal.ts b/application/i18n/locales/zh-CN/terminal.ts index 61afa796..361325dc 100644 --- a/application/i18n/locales/zh-CN/terminal.ts +++ b/application/i18n/locales/zh-CN/terminal.ts @@ -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', diff --git a/application/i18n/locales/zh-CN/vault.ts b/application/i18n/locales/zh-CN/vault.ts index 6bc66a4d..4fca1517 100644 --- a/application/i18n/locales/zh-CN/vault.ts +++ b/application/i18n/locales/zh-CN/vault.ts @@ -245,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', @@ -305,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}', diff --git a/application/state/sessionWorkspaceDetach.test.ts b/application/state/sessionWorkspaceDetach.test.ts new file mode 100644 index 00000000..60873802 --- /dev/null +++ b/application/state/sessionWorkspaceDetach.test.ts @@ -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"], + ); +}); diff --git a/application/state/sessionWorkspaceDetach.ts b/application/state/sessionWorkspaceDetach.ts new file mode 100644 index 00000000..8233a7ad --- /dev/null +++ b/application/state/sessionWorkspaceDetach.ts @@ -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, + }; +} diff --git a/application/state/terminalDragData.ts b/application/state/terminalDragData.ts new file mode 100644 index 00000000..7f1886e0 --- /dev/null +++ b/application/state/terminalDragData.ts @@ -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, type: string): boolean { + return Array.prototype.includes.call(dataTransfer.types, type); +} + +export function hasWorkspaceSessionDrag(dataTransfer: Pick): 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, +): 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('[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; +} diff --git a/application/state/useGlobalHotkeys.ts b/application/state/useGlobalHotkeys.ts index e9fed889..0c66cd69 100644 --- a/application/state/useGlobalHotkeys.ts +++ b/application/state/useGlobalHotkeys.ts @@ -23,6 +23,7 @@ export const getAppLevelActions = (): Set => { 'nextTab', 'prevTab', 'closeTab', + 'closeSession', 'newTab', 'openHosts', 'openSftp', @@ -35,6 +36,7 @@ export const getAppLevelActions = (): Set => { 'splitVertical', 'moveFocus', 'broadcast', + 'togglePaneZoom', 'openLocal', 'openSettings', ]); diff --git a/application/state/useSessionState.ts b/application/state/useSessionState.ts index 28c38b08..29da3b91 100644 --- a/application/state/useSessionState.ts +++ b/application/state/useSessionState.ts @@ -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, diff --git a/components/Terminal.tsx b/components/Terminal.tsx index c457ed78..10e23532 100644 --- a/components/Terminal.tsx +++ b/components/Terminal.tsx @@ -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 = ({ 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 = ({ 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 ; + return ; }; const Terminal = memo(TerminalComponent, terminalPropsAreEqual); diff --git a/components/TerminalLayer.tsx b/components/TerminalLayer.tsx index dcafead3..301891cd 100644 --- a/components/TerminalLayer.tsx +++ b/components/TerminalLayer.tsx @@ -124,6 +124,9 @@ const TerminalLayerInner: React.FC = ({ onToggleWorkspaceViewMode, onSetWorkspaceFocusedSession, onReorderWorkspaceSessions, + onReorderTabs, + onCopySession, + onCopySessionToNewWindow, onSplitSession, onConnectToHost, onCreateLocalTerminal, @@ -150,6 +153,10 @@ const TerminalLayerInner: React.FC = ({ showHostTreeSidebar = true, toggleScriptsSidePanelRef, toggleSidePanelRef, + // Session rename props + onStartSessionRename, + onSubmitSessionRename, + onRemoveSessionFromWorkspace, }) => { const { t } = useI18n(); const terminalRendererCwdBySessionRef = useRef>(new Map()); @@ -1138,10 +1145,18 @@ const TerminalLayerInner: React.FC = ({ onCreateWorkspaceFromSessions, onHotkeyAction, onReorderWorkspaceSessions, + onReorderTabs, + onCopySession, + onCopySessionToNewWindow, onRequestAddToWorkspace, onSessionData, onSetDraggingSessionId, onSetWorkspaceFocusedSession, + onStartSessionRename, + onSubmitSessionRename, + onRemoveSessionFromWorkspace, + onStartSessionDrag: onSetDraggingSessionId, + onEndSessionDrag: () => onSetDraggingSessionId(null), onSplitSession, onSplitSessionRef, onToggleBroadcastRef, diff --git a/components/TopTabs.test.ts b/components/TopTabs.test.ts index 5d1861ad..a78524a0 100644 --- a/components/TopTabs.test.ts +++ b/components/TopTabs.test.ts @@ -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\);/); diff --git a/components/TopTabs.tsx b/components/TopTabs.tsx index 161d6bf9..aeb31c6b 100644 --- a/components/TopTabs.tsx +++ b/components/TopTabs.tsx @@ -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 = ({ onStartSessionDrag, onEndSessionDrag, onReorderTabs, + onRemoveSessionFromWorkspace, showSftpTab, showHostTreeSidebar, editorTabs, @@ -386,7 +421,7 @@ const TopTabsInner: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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) => { const target = e.target as HTMLElement; @@ -682,6 +768,14 @@ const TopTabsInner: React.FC = ({ 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 = {}; + for (const sessionId of workspaceSessionIds) { + const wsSession = sessions.find((s) => s.id === sessionId); + if (wsSession) { + workspaceSessionLabels[sessionId] = wsSession.customName || wsSession.hostLabel; + } + } return ( = ({ 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 = ({ 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 && (
= ({ 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 */} diff --git a/components/terminal/SessionInlineRenameInput.tsx b/components/terminal/SessionInlineRenameInput.tsx new file mode 100644 index 00000000..5c910328 --- /dev/null +++ b/components/terminal/SessionInlineRenameInput.tsx @@ -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 = ({ + initialName, + onCommit, + onCancel, + className, + style, +}) => { + const inputRef = useRef(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 ( + 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} + /> + ); +}; diff --git a/components/terminal/TerminalContextMenu.tsx b/components/terminal/TerminalContextMenu.tsx index e6bb56c8..500f4429 100644 --- a/components/terminal/TerminalContextMenu.tsx +++ b/components/terminal/TerminalContextMenu.tsx @@ -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 = ({ onClose, onSelectWord, onAddSelectionToAI, + onRename, + onDetach, }) => { const { t } = useI18n(); const isMac = hotkeyScheme === 'mac'; @@ -299,6 +305,26 @@ export const TerminalContextMenu: React.FC = ({ {clearShortcut} + {onRename && ( + <> + + + + {t('terminal.menu.rename')} + + + )} + + {onDetach && ( + <> + + + + {t('terminal.menu.detach')} + + + )} + {onClose && ( <> diff --git a/components/terminal/TerminalServerStats.tsx b/components/terminal/TerminalServerStats.tsx index c50e09cc..146fd1a3 100644 --- a/components/terminal/TerminalServerStats.tsx +++ b/components/terminal/TerminalServerStats.tsx @@ -47,16 +47,16 @@ export const TerminalServerStats: React.FC = ({ if (!enabled || !isConnected || !serverStats.lastUpdated) return null; return ( -
+
{/* CPU with HoverCard for per-core details */} onCloseSession?.(sessionId) : undefined} onAddSelectionToAI={ctx.onAddSelectionToAI ? handleAddSelectionToAI : undefined} + onRename={onRename} + onDetach={inWorkspace ? onDetach : undefined} >
-
- {host.label} - +
+ data-terminal-detach-drag-handle={inWorkspace && onDetachPointerDown ? "true" : undefined} + onPointerDown={onDetachPointerDown} + > + {sessionDisplayName || host.label} + +
{shouldShowLineTimestampToolbarToggle(lineTimestampsAvailable, onUpdateHost) && ( @@ -266,7 +281,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) { isVisible={isVisible} /> )} -
+
{inWorkspace && onToggleBroadcast && ( @@ -296,6 +311,22 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) { )} + {inWorkspace && onDetach && ( + + + + + {t('terminal.toolbar.detach')} + + )} {inWorkspace && !isFocusMode && onExpandToFocus && ( diff --git a/components/terminal/terminalHelpers.ts b/components/terminal/terminalHelpers.ts index 5cf5953f..4b4fcb0f 100644 --- a/components/terminal/terminalHelpers.ts +++ b/components/terminal/terminalHelpers.ts @@ -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) => void; + onDetachDragStart?: (e: DragEvent) => void; + onDetachDragEnd?: (e: DragEvent) => void; } export function formatNetSpeed(bytesPerSec: number): string { diff --git a/components/terminal/terminalMemo.ts b/components/terminal/terminalMemo.ts index efc26c5b..9a4f9ba1 100644 --- a/components/terminal/terminalMemo.ts +++ b/components/terminal/terminalMemo.ts @@ -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 ); diff --git a/components/terminal/toolbarFocus.test.ts b/components/terminal/toolbarFocus.test.ts index 9f126a92..a6f2a882 100644 --- a/components/terminal/toolbarFocus.test.ts +++ b/components/terminal/toolbarFocus.test.ts @@ -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); +}); diff --git a/components/terminal/toolbarFocus.ts b/components/terminal/toolbarFocus.ts index 9af6d297..f473e92d 100644 --- a/components/terminal/toolbarFocus.ts +++ b/components/terminal/toolbarFocus.ts @@ -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; } diff --git a/components/terminalLayer/TerminalFocusSidebar.tsx b/components/terminalLayer/TerminalFocusSidebar.tsx index aeda6fd4..2d48524e 100644 --- a/components/terminalLayer/TerminalFocusSidebar.tsx +++ b/components/terminalLayer/TerminalFocusSidebar.tsx @@ -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; 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(({ session, host, isSelected, + isRenaming, + renameValue, + onStartRename, + onSubmitRename, + onCancelRename, + onCloseSession, + onCopySession, + onCopySessionToNewWindow, + onDetachSessionFromWorkspace, isDragging, dropPosition, theme, @@ -62,6 +89,7 @@ const WorkspaceFocusSessionRow = memo(({ onDragOver, onDrop, onDragEnd, + t, }) => { const { termFg, @@ -83,80 +111,121 @@ const WorkspaceFocusSessionRow = memo(({ const rowFg = isSelected ? termFg : unselectedFg; return ( -
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); - }} - > -
- {host ? ( - - ) : ( - - )} - -
-
-
- {session.hostLabel} + + +
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); + }} + > +
+ {host ? ( + + ) : ( + + )} + +
+
+ {isRenaming ? ( + + ) : ( + <> +
{ + e.stopPropagation(); + onStartRename(session.id); + }} + > + {session.customName || session.hostLabel} +
+
+ {session.username}@{session.hostname} +
+ + )} +
-
- {session.username}@{session.hostname} -
-
-
+ + + ); }, (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 = ({ focusedSessionId, onReorderWorkspaceSessions, onRequestAddToWorkspace, + onCloseSession, + onCopySession, + onCopySessionToNewWindow, + onDetachSessionFromWorkspace, onSetWorkspaceFocusedSession, onToggleWorkspaceViewMode, + onSubmitSessionRename, resolvedPreviewTheme, sessionHostsMap, sessions, @@ -182,6 +256,9 @@ const TerminalFocusSidebarInner: React.FC = ({ STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH, 224, { min: 160, max: 480 }, ); + const [sidebarRenameSessionId, setSidebarRenameSessionId] = useState(null); + const [sidebarRenameValue, setSidebarRenameValue] = useState(''); + const theme = useMemo(() => { const termBg = resolvedPreviewTheme.colors.background; const termFg = resolvedPreviewTheme.colors.foreground; @@ -208,7 +285,8 @@ const TerminalFocusSidebarInner: React.FC = ({ 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 = ({ 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 (
= ({ 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 = ({ onDragOver={handleFocusSidebarDragOver} onDrop={handleFocusSidebarDrop} onDragEnd={handleFocusSidebarDragEnd} + t={t} /> ))}
@@ -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; diff --git a/components/terminalLayer/TerminalLayerFocusSidebarSection.tsx b/components/terminalLayer/TerminalLayerFocusSidebarSection.tsx index bda0871e..865f9a84 100644 --- a/components/terminalLayer/TerminalLayerFocusSidebarSection.tsx +++ b/components/terminalLayer/TerminalLayerFocusSidebarSection.tsx @@ -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} diff --git a/components/terminalLayer/TerminalLayerSupport.tsx b/components/terminalLayer/TerminalLayerSupport.tsx index 7e215d15..ddf0afe5 100644 --- a/components/terminalLayer/TerminalLayerSupport.tsx +++ b/components/terminalLayer/TerminalLayerSupport.tsx @@ -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 = memo(({ @@ -743,6 +767,11 @@ const TerminalPane: React.FC = memo(({ onSnippetExecutorChange, onAddSelectionToAI, showSelectionAIAction, + onStartSessionRename, + onRemoveSessionFromWorkspace, + onReorderTabs, + onStartSessionDrag, + onEndSessionDrag, }) => { const layoutSuppressActive = useTerminalLayoutSuppressActive(); const deferPaneLayoutUpdate = isResizing || layoutSuppressActive; @@ -855,6 +884,192 @@ const TerminalPane: React.FC = 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) => { + 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('[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('[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) => { + 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('[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 = 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} />
); @@ -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; diff --git a/components/terminalLayer/TerminalLayerTabBridge.tsx b/components/terminalLayer/TerminalLayerTabBridge.tsx index b679681a..00bdb079 100644 --- a/components/terminalLayer/TerminalLayerTabBridge.tsx +++ b/components/terminalLayer/TerminalLayerTabBridge.tsx @@ -392,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, diff --git a/components/terminalLayer/TerminalLayerWorkspaceSection.tsx b/components/terminalLayer/TerminalLayerWorkspaceSection.tsx index 3af72124..2e7db24e 100644 --- a/components/terminalLayer/TerminalLayerWorkspaceSection.tsx +++ b/components/terminalLayer/TerminalLayerWorkspaceSection.tsx @@ -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'; diff --git a/components/terminalLayer/terminalLayerStableSnapshot.ts b/components/terminalLayer/terminalLayerStableSnapshot.ts index 86478298..25c082b0 100644 --- a/components/terminalLayer/terminalLayerStableSnapshot.ts +++ b/components/terminalLayer/terminalLayerStableSnapshot.ts @@ -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']; diff --git a/components/terminalLayer/terminalLayerViewMemo.ts b/components/terminalLayer/terminalLayerViewMemo.ts index 3fd0ba8f..e950de2c 100644 --- a/components/terminalLayer/terminalLayerViewMemo.ts +++ b/components/terminalLayer/terminalLayerViewMemo.ts @@ -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'); } diff --git a/components/terminalLayerMemo.ts b/components/terminalLayerMemo.ts index 4bd64a62..3cd2af16 100644 --- a/components/terminalLayerMemo.ts +++ b/components/terminalLayerMemo.ts @@ -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 && diff --git a/components/top-tabs/SessionTabContextMenuContent.tsx b/components/top-tabs/SessionTabContextMenuContent.tsx new file mode 100644 index 00000000..5a56a5ba --- /dev/null +++ b/components/top-tabs/SessionTabContextMenuContent.tsx @@ -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['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 ( + + onRenameSession(sessionId)}> + {t('common.rename')} + + {onCopySession && ( + onCopySession(sessionId)}> + {t('tabs.copyTab')} + + )} + {onCopySessionToNewWindow && ( + onCopySessionToNewWindow(sessionId)}> + {t('tabs.copyTabToNewWindow')} + + )} + {onDetachSession && ( + onDetachSession(sessionId)}> + {t('terminal.menu.detach')} + + )} + onCloseSession(sessionId)}> + {t('common.close')} + + {renderBulkCloseItems?.(sessionId)} + + ); +} diff --git a/components/top-tabs/TopTabItems.tsx b/components/top-tabs/TopTabItems.tsx index f8509ed5..97ddd235 100644 --- a/components/top-tabs/TopTabItems.tsx +++ b/components/top-tabs/TopTabItems.tsx @@ -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 = memo(({ )}
- {session.hostLabel} + {session.customName || session.hostLabel}
{sessionStatusDot(session.status, hasActivity)}
- - onRenameSession(session.id)}> - {t('common.rename')} - - onCopySession(session.id)}> - {t('tabs.copyTab')} - - onCopySessionToNewWindow(session.id)}> - {t('tabs.copyTabToNewWindow')} - - onCloseSession(session.id)}> - {t('common.close')} - - {renderBulkCloseItems(session.id)} - + ); }); @@ -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; renderBulkCloseItems: RenderBulkCloseItems; t: TranslateFn; tabAnimationClass?: string; @@ -624,6 +621,8 @@ export const WorkspaceTopTab: React.FC = memo(({ onTabDrop, onRenameWorkspace, onCloseWorkspace, + onDetachSessionFromWorkspace, + workspaceSessionLabels, renderBulkCloseItems, t, tabAnimationClass, @@ -715,6 +714,17 @@ export const WorkspaceTopTab: React.FC = memo(({ onRenameWorkspace(workspace.id)}> {t('common.rename')} + {onDetachSessionFromWorkspace && workspaceSessionLabels && Object.entries(workspaceSessionLabels).map(([sessionId, label]) => ( + onDetachSessionFromWorkspace(workspace.id, sessionId)} + > + {t('terminal.menu.detachSession', { name: label })} + + ))} + {onDetachSessionFromWorkspace && workspaceSessionLabels && Object.keys(workspaceSessionLabels).length > 0 && ( + + )} onCloseWorkspace(workspace.id)}> {t('common.close')} diff --git a/domain/models/keyBindings.ts b/domain/models/keyBindings.ts index 5088b9a5..f7a67b7a 100644 --- a/domain/models/keyBindings.ts +++ b/domain/models/keyBindings.ts @@ -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' }, diff --git a/domain/models/terminal.ts b/domain/models/terminal.ts index 26364b1a..1fd98cac 100644 --- a/domain/models/terminal.ts +++ b/domain/models/terminal.ts @@ -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; } diff --git a/index.css b/index.css index 213ddaab..771d5a96 100644 --- a/index.css +++ b/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;