feat: terminal rename, closeSession shortcut, and pane zoom (#1459)
* 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 <cursoragent@cursor.com>
* feat: add detach session from workspace with toolbar button and context menu
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: use customName in pane header display name for renamed sessions
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: refine workspace terminal detach interactions
* fix: preserve workspace detach tab ordering
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
6
App.tsx
6
App.tsx
@@ -216,6 +216,7 @@ function App({ settings }: { settings: SettingsState }) {
|
|||||||
sessionRenameValue,
|
sessionRenameValue,
|
||||||
setSessionRenameValue,
|
setSessionRenameValue,
|
||||||
startSessionRename,
|
startSessionRename,
|
||||||
|
renameSessionInline,
|
||||||
submitSessionRename,
|
submitSessionRename,
|
||||||
resetSessionRename,
|
resetSessionRename,
|
||||||
workspaceRenameTarget,
|
workspaceRenameTarget,
|
||||||
@@ -235,6 +236,7 @@ function App({ settings }: { settings: SettingsState }) {
|
|||||||
createWorkspaceWithHosts,
|
createWorkspaceWithHosts,
|
||||||
createWorkspaceFromSessions,
|
createWorkspaceFromSessions,
|
||||||
addSessionToWorkspace,
|
addSessionToWorkspace,
|
||||||
|
removeSessionFromWorkspace,
|
||||||
appendHostToWorkspace,
|
appendHostToWorkspace,
|
||||||
appendLocalTerminalToWorkspace,
|
appendLocalTerminalToWorkspace,
|
||||||
createWorkspaceFromTargets,
|
createWorkspaceFromTargets,
|
||||||
@@ -728,7 +730,7 @@ function App({ settings }: { settings: SettingsState }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
// 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 handleWindowCommandCloseRequest = useCallback(async () => {
|
||||||
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
|
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
|
||||||
@@ -988,7 +990,7 @@ function App({ settings }: { settings: SettingsState }) {
|
|||||||
logViews={logViews}
|
logViews={logViews}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -440,7 +440,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
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.
|
// 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
|
// 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;
|
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 'newTab':
|
||||||
case 'openLocal':
|
case 'openLocal':
|
||||||
// Add connection log for local terminal
|
// Add connection log for local terminal
|
||||||
@@ -644,6 +678,15 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
|||||||
}
|
}
|
||||||
break;
|
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': {
|
case 'moveFocus': {
|
||||||
// Debounce to prevent double-triggering when focus switches between terminals
|
// Debounce to prevent double-triggering when focus switches between terminals
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
|||||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal,
|
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal,
|
||||||
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions,
|
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,
|
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||||
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
|
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
|
||||||
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, updateSessionFontSize, clearSessionFontSizeOverride,
|
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, updateSessionFontSize, clearSessionFontSizeOverride,
|
||||||
@@ -134,6 +134,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
|||||||
onStartSessionDrag={setDraggingSessionId}
|
onStartSessionDrag={setDraggingSessionId}
|
||||||
onEndSessionDrag={handleEndSessionDrag}
|
onEndSessionDrag={handleEndSessionDrag}
|
||||||
onReorderTabs={reorderWorkTabs}
|
onReorderTabs={reorderWorkTabs}
|
||||||
|
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||||
showSftpTab={settings.showSftpTab}
|
showSftpTab={settings.showSftpTab}
|
||||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||||
editorTabs={editorTabs}
|
editorTabs={editorTabs}
|
||||||
@@ -281,6 +282,9 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
|||||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||||
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
|
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
|
||||||
onReorderWorkspaceSessions={reorderWorkspaceSessions}
|
onReorderWorkspaceSessions={reorderWorkspaceSessions}
|
||||||
|
onReorderTabs={reorderWorkTabs}
|
||||||
|
onCopySession={copySessionWithCurrentShell}
|
||||||
|
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||||
onSplitSession={splitSessionWithCurrentShell}
|
onSplitSession={splitSessionWithCurrentShell}
|
||||||
onConnectToHost={handleConnectToHost}
|
onConnectToHost={handleConnectToHost}
|
||||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||||
@@ -307,6 +311,9 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
|||||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||||
toggleSidePanelRef={toggleSidePanelRef}
|
toggleSidePanelRef={toggleSidePanelRef}
|
||||||
|
onStartSessionRename={startSessionRename}
|
||||||
|
onSubmitSessionRename={submitSessionRename}
|
||||||
|
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Log Views - readonly terminal replays */}
|
{/* Log Views - readonly terminal replays */}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
isHostTreeWorkTabSurface,
|
isHostTreeWorkTabSurface,
|
||||||
isRootPageTabId,
|
isRootPageTabId,
|
||||||
isTerminalContentTabSurface,
|
isTerminalContentTabSurface,
|
||||||
|
reorderWorkTabIds,
|
||||||
resolveWorkTabActiveHostId,
|
resolveWorkTabActiveHostId,
|
||||||
} from './workTabSurface';
|
} from './workTabSurface';
|
||||||
import type { EditorTab } from '../state/editorTabStore';
|
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', () => {
|
test('root pages are not work tab surfaces', () => {
|
||||||
assert.equal(isRootPageTabId('vault'), true);
|
assert.equal(isRootPageTabId('vault'), true);
|
||||||
assert.equal(isRootPageTabId('sftp'), true);
|
assert.equal(isRootPageTabId('sftp'), true);
|
||||||
|
|||||||
@@ -5,6 +5,17 @@ import {
|
|||||||
import type { EditorTab } from '../state/editorTabStore';
|
import type { EditorTab } from '../state/editorTabStore';
|
||||||
import type { TerminalSession, Workspace } from '../../types';
|
import type { TerminalSession, Workspace } from '../../types';
|
||||||
|
|
||||||
|
function uniqueTabIds(tabIds: readonly string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const uniqueIds: string[] = [];
|
||||||
|
for (const tabId of tabIds) {
|
||||||
|
if (!tabId || seen.has(tabId)) continue;
|
||||||
|
seen.add(tabId);
|
||||||
|
uniqueIds.push(tabId);
|
||||||
|
}
|
||||||
|
return uniqueIds;
|
||||||
|
}
|
||||||
|
|
||||||
export function isRootPageTabId(activeTabId: string): boolean {
|
export function isRootPageTabId(activeTabId: string): boolean {
|
||||||
return activeTabId === 'vault' || activeTabId === 'sftp';
|
return activeTabId === 'vault' || activeTabId === 'sftp';
|
||||||
}
|
}
|
||||||
@@ -13,13 +24,42 @@ export function buildOrderedWorkTabIds(
|
|||||||
tabOrder: readonly string[],
|
tabOrder: readonly string[],
|
||||||
allTabIds: readonly string[],
|
allTabIds: readonly string[],
|
||||||
): string[] {
|
): string[] {
|
||||||
const allTabIdSet = new Set(allTabIds);
|
const uniqueAllTabIds = uniqueTabIds(allTabIds);
|
||||||
const orderedIds = tabOrder.filter((id) => allTabIdSet.has(id));
|
const allTabIdSet = new Set(uniqueAllTabIds);
|
||||||
|
const orderedIds = uniqueTabIds(tabOrder.filter((id) => allTabIdSet.has(id)));
|
||||||
const orderedIdSet = new Set(orderedIds);
|
const orderedIdSet = new Set(orderedIds);
|
||||||
const newIds = allTabIds.filter((id) => !orderedIdSet.has(id));
|
const newIds = uniqueAllTabIds.filter((id) => !orderedIdSet.has(id));
|
||||||
return [...orderedIds, ...newIds];
|
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({
|
export function isHostTreeWorkTabSurface({
|
||||||
enabled,
|
enabled,
|
||||||
activeTabId,
|
activeTabId,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const enTerminalMessages: Messages = {
|
|||||||
'terminal.composeBar.snippetClickHint': 'Click to insert · Shift+Click to send',
|
'terminal.composeBar.snippetClickHint': 'Click to insert · Shift+Click to send',
|
||||||
'terminal.toolbar.focus': 'Focus',
|
'terminal.toolbar.focus': 'Focus',
|
||||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||||
|
'terminal.toolbar.detach': 'Detach to standalone tab',
|
||||||
'terminal.toolbar.encoding': 'Terminal Encoding',
|
'terminal.toolbar.encoding': 'Terminal Encoding',
|
||||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||||
@@ -111,6 +112,9 @@ export const enTerminalMessages: Messages = {
|
|||||||
'terminal.menu.splitVertical': 'Split Vertical',
|
'terminal.menu.splitVertical': 'Split Vertical',
|
||||||
'terminal.menu.clearBuffer': 'Clear Buffer',
|
'terminal.menu.clearBuffer': 'Clear Buffer',
|
||||||
'terminal.menu.closeTerminal': 'Close terminal',
|
'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.selectFile': 'Select file to send',
|
||||||
'terminal.ymodem.allFiles': 'All files',
|
'terminal.ymodem.allFiles': 'All files',
|
||||||
'terminal.ymodem.started': 'YMODEM sending {fileName}',
|
'terminal.ymodem.started': 'YMODEM sending {fileName}',
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export const ruTerminalMessages: Messages = {
|
|||||||
'terminal.composeBar.snippetClickHint': 'Клик — вставить · Shift+клик — отправить',
|
'terminal.composeBar.snippetClickHint': 'Клик — вставить · Shift+клик — отправить',
|
||||||
'terminal.toolbar.focus': 'Фокус',
|
'terminal.toolbar.focus': 'Фокус',
|
||||||
'terminal.toolbar.focusMode': 'Режим фокуса',
|
'terminal.toolbar.focusMode': 'Режим фокуса',
|
||||||
|
'terminal.toolbar.detach': 'Открепить в отдельную вкладку',
|
||||||
'terminal.toolbar.encoding': 'Кодировка терминала',
|
'terminal.toolbar.encoding': 'Кодировка терминала',
|
||||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||||
@@ -132,6 +133,9 @@ export const ruTerminalMessages: Messages = {
|
|||||||
'terminal.menu.splitVertical': 'Разделить по вертикали',
|
'terminal.menu.splitVertical': 'Разделить по вертикали',
|
||||||
'terminal.menu.clearBuffer': 'Очистить буфер',
|
'terminal.menu.clearBuffer': 'Очистить буфер',
|
||||||
'terminal.menu.closeTerminal': 'Закрыть терминал',
|
'terminal.menu.closeTerminal': 'Закрыть терминал',
|
||||||
|
'terminal.menu.rename': 'Переименовать',
|
||||||
|
'terminal.menu.detach': 'Открепить из рабочей области',
|
||||||
|
'terminal.menu.detachSession': 'Открепить {name}',
|
||||||
'terminal.ymodem.selectFile': 'Выберите файл для отправки',
|
'terminal.ymodem.selectFile': 'Выберите файл для отправки',
|
||||||
'terminal.ymodem.allFiles': 'Все файлы',
|
'terminal.ymodem.allFiles': 'Все файлы',
|
||||||
'terminal.ymodem.started': 'YMODEM отправляет {fileName}',
|
'terminal.ymodem.started': 'YMODEM отправляет {fileName}',
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import type { Messages } from '../types';
|
|||||||
|
|
||||||
export const zhCNTerminalMessages: Messages = {
|
export const zhCNTerminalMessages: Messages = {
|
||||||
'terminal.sudoHint.pressEnter': '按 Enter 粘贴 sudo 密码',
|
'terminal.sudoHint.pressEnter': '按 Enter 粘贴 sudo 密码',
|
||||||
|
'terminal.menu.rename': '重命名',
|
||||||
|
'terminal.toolbar.detach': '移出到独立标签',
|
||||||
|
'terminal.menu.detach': '从工作区移出',
|
||||||
'terminal.toolbar.timestampsEnable': '显示时间戳',
|
'terminal.toolbar.timestampsEnable': '显示时间戳',
|
||||||
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
|
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
|
||||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ export const zhCNVaultMessages: Messages = {
|
|||||||
'terminal.composeBar.snippetClickHint': '单击插入 · Shift+单击直接发送',
|
'terminal.composeBar.snippetClickHint': '单击插入 · Shift+单击直接发送',
|
||||||
'terminal.toolbar.focus': '聚焦',
|
'terminal.toolbar.focus': '聚焦',
|
||||||
'terminal.toolbar.focusMode': '聚焦模式',
|
'terminal.toolbar.focusMode': '聚焦模式',
|
||||||
|
'terminal.toolbar.detach': '移出到独立标签',
|
||||||
'terminal.toolbar.encoding': '终端编码',
|
'terminal.toolbar.encoding': '终端编码',
|
||||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||||
@@ -305,6 +306,9 @@ export const zhCNVaultMessages: Messages = {
|
|||||||
'terminal.menu.splitVertical': '垂直分屏',
|
'terminal.menu.splitVertical': '垂直分屏',
|
||||||
'terminal.menu.clearBuffer': '清空缓冲区',
|
'terminal.menu.clearBuffer': '清空缓冲区',
|
||||||
'terminal.menu.closeTerminal': '关闭终端',
|
'terminal.menu.closeTerminal': '关闭终端',
|
||||||
|
'terminal.menu.rename': '重命名',
|
||||||
|
'terminal.menu.detach': '从工作区移出',
|
||||||
|
'terminal.menu.detachSession': '移出 {name}',
|
||||||
'terminal.ymodem.selectFile': '选择要发送的文件',
|
'terminal.ymodem.selectFile': '选择要发送的文件',
|
||||||
'terminal.ymodem.allFiles': '所有文件',
|
'terminal.ymodem.allFiles': '所有文件',
|
||||||
'terminal.ymodem.started': '正在通过 YMODEM 发送 {fileName}',
|
'terminal.ymodem.started': '正在通过 YMODEM 发送 {fileName}',
|
||||||
|
|||||||
123
application/state/sessionWorkspaceDetach.test.ts
Normal file
123
application/state/sessionWorkspaceDetach.test.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||||
|
import {
|
||||||
|
closeSessionWorkspaceLayoutState,
|
||||||
|
detachSessionFromWorkspaceState,
|
||||||
|
replaceDissolvedWorkspaceTabOrder,
|
||||||
|
} from "./sessionWorkspaceDetach";
|
||||||
|
|
||||||
|
const session = (id: string, workspaceId = "ws-1"): TerminalSession => ({
|
||||||
|
id,
|
||||||
|
hostId: id,
|
||||||
|
hostLabel: id,
|
||||||
|
status: "connected",
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspace = (sessionIds: string[]): Workspace => ({
|
||||||
|
id: "ws-1",
|
||||||
|
title: "Workspace",
|
||||||
|
focusedSessionId: sessionIds[0],
|
||||||
|
focusSessionOrder: sessionIds,
|
||||||
|
root: sessionIds.length === 1
|
||||||
|
? { id: "pane-1", type: "pane", sessionId: sessionIds[0] }
|
||||||
|
: {
|
||||||
|
id: "split-1",
|
||||||
|
type: "split",
|
||||||
|
direction: "vertical",
|
||||||
|
children: sessionIds.map((sessionId, index) => ({
|
||||||
|
id: `pane-${index + 1}`,
|
||||||
|
type: "pane" as const,
|
||||||
|
sessionId,
|
||||||
|
})),
|
||||||
|
sizes: sessionIds.map(() => 1),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detach dissolves the original workspace when one session remains", () => {
|
||||||
|
const result = detachSessionFromWorkspaceState({
|
||||||
|
sessions: [session("s1"), session("s2")],
|
||||||
|
workspaces: [workspace(["s1", "s2"])],
|
||||||
|
sessionId: "s1",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
assert.equal(result.activeTabId, "s1");
|
||||||
|
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||||||
|
["s1", undefined],
|
||||||
|
["s2", undefined],
|
||||||
|
]);
|
||||||
|
assert.equal(result.workspaces.length, 0);
|
||||||
|
assert.equal(result.dissolvedWorkspaceId, "ws-1");
|
||||||
|
assert.deepEqual(result.replacementTabIds, ["s1", "s2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detach preserves the other sessions in a multi-pane workspace", () => {
|
||||||
|
const result = detachSessionFromWorkspaceState({
|
||||||
|
sessions: [session("s1"), session("s2"), session("s3")],
|
||||||
|
workspaces: [workspace(["s1", "s2", "s3"])],
|
||||||
|
sessionId: "s2",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||||||
|
["s1", "ws-1"],
|
||||||
|
["s2", undefined],
|
||||||
|
["s3", "ws-1"],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s3"]);
|
||||||
|
assert.equal(result.workspaces[0].focusedSessionId, "s1");
|
||||||
|
assert.deepEqual(
|
||||||
|
result.workspaces[0].root.type === "split"
|
||||||
|
? result.workspaces[0].root.children.map((child) => child.type === "pane" ? child.sessionId : null)
|
||||||
|
: [],
|
||||||
|
["s1", "s3"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dissolved workspace replacement preserves its tab position", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
|
||||||
|
["log-1", "s1", "s2", "session-3"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dissolved workspace replacement removes duplicate replacement ids", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
replaceDissolvedWorkspaceTabOrder(["s1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
|
||||||
|
["s1", "s2", "session-3"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dissolved workspace replacement is idempotent", () => {
|
||||||
|
const once = replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
replaceDissolvedWorkspaceTabOrder(once, "ws-1", ["s1", "s2"]),
|
||||||
|
once,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("single remaining session preserves dissolved workspace tab position", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s2"]),
|
||||||
|
["log-1", "s2", "session-3"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing a workspace session dissolves the workspace when one terminal remains", () => {
|
||||||
|
const result = closeSessionWorkspaceLayoutState([workspace(["s1", "s2"])], "ws-1", "s1");
|
||||||
|
|
||||||
|
assert.equal(result.dissolvedWorkspaceId, "ws-1");
|
||||||
|
assert.equal(result.lastRemainingSessionId, "s2");
|
||||||
|
assert.deepEqual(result.workspaces, []);
|
||||||
|
assert.deepEqual(
|
||||||
|
replaceDissolvedWorkspaceTabOrder(
|
||||||
|
["log-1", result.dissolvedWorkspaceId!, "session-3"],
|
||||||
|
result.dissolvedWorkspaceId,
|
||||||
|
result.lastRemainingSessionId ? [result.lastRemainingSessionId] : undefined,
|
||||||
|
),
|
||||||
|
["log-1", "s2", "session-3"],
|
||||||
|
);
|
||||||
|
});
|
||||||
182
application/state/sessionWorkspaceDetach.ts
Normal file
182
application/state/sessionWorkspaceDetach.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||||
|
import { collectSessionIds, pruneWorkspaceNode } from "../../domain/workspace";
|
||||||
|
|
||||||
|
export type DetachSessionFromWorkspaceStateResult = {
|
||||||
|
changed: boolean;
|
||||||
|
sessions: TerminalSession[];
|
||||||
|
workspaces: Workspace[];
|
||||||
|
activeTabId?: string;
|
||||||
|
dissolvedWorkspaceId?: string;
|
||||||
|
replacementTabIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CloseSessionWorkspaceLayoutResult = {
|
||||||
|
workspaces: Workspace[];
|
||||||
|
removedWorkspaceId?: string;
|
||||||
|
dissolvedWorkspaceId?: string;
|
||||||
|
lastRemainingSessionId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DetachSessionFromWorkspaceStateOptions = {
|
||||||
|
sessions: TerminalSession[];
|
||||||
|
workspaces: Workspace[];
|
||||||
|
sessionId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function replaceDissolvedWorkspaceTabOrder(
|
||||||
|
tabOrder: readonly string[],
|
||||||
|
workspaceId: string | undefined,
|
||||||
|
replacementTabIds: readonly string[] | undefined,
|
||||||
|
): string[] {
|
||||||
|
if (!workspaceId || !replacementTabIds?.length) return [...tabOrder];
|
||||||
|
|
||||||
|
const uniqueReplacementIds = replacementTabIds.filter((tabId, index, list) => (
|
||||||
|
tabId && list.indexOf(tabId) === index
|
||||||
|
));
|
||||||
|
if (uniqueReplacementIds.length === 0) return [...tabOrder];
|
||||||
|
|
||||||
|
if (!tabOrder.includes(workspaceId)) {
|
||||||
|
const hasAllReplacementIds = uniqueReplacementIds.every((tabId) => tabOrder.includes(tabId));
|
||||||
|
return hasAllReplacementIds ? [...tabOrder] : [
|
||||||
|
...tabOrder,
|
||||||
|
...uniqueReplacementIds.filter((tabId) => !tabOrder.includes(tabId)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const replacementIdSet = new Set(uniqueReplacementIds);
|
||||||
|
let inserted = false;
|
||||||
|
const nextOrder: string[] = [];
|
||||||
|
|
||||||
|
for (const tabId of tabOrder) {
|
||||||
|
if (tabId === workspaceId) {
|
||||||
|
if (!inserted) {
|
||||||
|
nextOrder.push(...uniqueReplacementIds);
|
||||||
|
inserted = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!replacementIdSet.has(tabId)) {
|
||||||
|
nextOrder.push(tabId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSessionWorkspaceLayoutState(
|
||||||
|
workspaces: readonly Workspace[],
|
||||||
|
workspaceId: string | undefined,
|
||||||
|
sessionId: string,
|
||||||
|
): CloseSessionWorkspaceLayoutResult {
|
||||||
|
if (!workspaceId) return { workspaces: [...workspaces] };
|
||||||
|
|
||||||
|
let removedWorkspaceId: string | undefined;
|
||||||
|
let dissolvedWorkspaceId: string | undefined;
|
||||||
|
let lastRemainingSessionId: string | undefined;
|
||||||
|
const nextWorkspaces = workspaces
|
||||||
|
.map((workspace) => {
|
||||||
|
if (workspace.id !== workspaceId) return workspace;
|
||||||
|
const prunedRoot = pruneWorkspaceNode(workspace.root, sessionId);
|
||||||
|
if (!prunedRoot) {
|
||||||
|
removedWorkspaceId = workspace.id;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingSessionIds = collectSessionIds(prunedRoot);
|
||||||
|
if (remainingSessionIds.length === 1) {
|
||||||
|
dissolvedWorkspaceId = workspace.id;
|
||||||
|
lastRemainingSessionId = remainingSessionIds[0];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...workspace, root: prunedRoot };
|
||||||
|
})
|
||||||
|
.filter((workspace): workspace is Workspace => Boolean(workspace));
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaces: nextWorkspaces,
|
||||||
|
removedWorkspaceId,
|
||||||
|
dissolvedWorkspaceId,
|
||||||
|
lastRemainingSessionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detachSessionFromWorkspaceState({
|
||||||
|
sessions,
|
||||||
|
workspaces,
|
||||||
|
sessionId,
|
||||||
|
}: DetachSessionFromWorkspaceStateOptions): DetachSessionFromWorkspaceStateResult {
|
||||||
|
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||||
|
if (!session?.workspaceId) {
|
||||||
|
return { changed: false, sessions, workspaces };
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceId = session.workspaceId;
|
||||||
|
const targetWorkspace = workspaces.find((workspace) => workspace.id === workspaceId);
|
||||||
|
if (!targetWorkspace) {
|
||||||
|
return { changed: false, sessions, workspaces };
|
||||||
|
}
|
||||||
|
|
||||||
|
const prunedRoot = pruneWorkspaceNode(targetWorkspace.root, sessionId);
|
||||||
|
let nextSessions = sessions.map((candidate) => (
|
||||||
|
candidate.id === sessionId ? { ...candidate, workspaceId: undefined } : candidate
|
||||||
|
));
|
||||||
|
|
||||||
|
if (!prunedRoot) {
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
sessions: nextSessions,
|
||||||
|
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
|
||||||
|
activeTabId: sessionId,
|
||||||
|
dissolvedWorkspaceId: workspaceId,
|
||||||
|
replacementTabIds: [sessionId],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingSessionIds = collectSessionIds(prunedRoot);
|
||||||
|
if (remainingSessionIds.length === 1) {
|
||||||
|
nextSessions = nextSessions.map((candidate) => (
|
||||||
|
candidate.id === remainingSessionIds[0] ? { ...candidate, workspaceId: undefined } : candidate
|
||||||
|
));
|
||||||
|
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
sessions: nextSessions,
|
||||||
|
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
|
||||||
|
activeTabId: sessionId,
|
||||||
|
dissolvedWorkspaceId: workspaceId,
|
||||||
|
replacementTabIds: [sessionId, ...remainingSessionIds],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextFocusedSessionId = remainingSessionIds.includes(targetWorkspace.focusedSessionId)
|
||||||
|
? targetWorkspace.focusedSessionId
|
||||||
|
: remainingSessionIds[0];
|
||||||
|
const nextFocusSessionOrder = (targetWorkspace.focusSessionOrder ?? [])
|
||||||
|
.filter((candidateId, index, list) => (
|
||||||
|
candidateId !== sessionId &&
|
||||||
|
remainingSessionIds.includes(candidateId) &&
|
||||||
|
list.indexOf(candidateId) === index
|
||||||
|
));
|
||||||
|
for (const remainingSessionId of remainingSessionIds) {
|
||||||
|
if (!nextFocusSessionOrder.includes(remainingSessionId)) {
|
||||||
|
nextFocusSessionOrder.push(remainingSessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
sessions: nextSessions,
|
||||||
|
workspaces: workspaces.map((workspace) => (
|
||||||
|
workspace.id === workspaceId
|
||||||
|
? {
|
||||||
|
...workspace,
|
||||||
|
root: prunedRoot,
|
||||||
|
focusedSessionId: nextFocusedSessionId,
|
||||||
|
focusSessionOrder: nextFocusSessionOrder,
|
||||||
|
}
|
||||||
|
: workspace
|
||||||
|
)),
|
||||||
|
activeTabId: sessionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
62
application/state/terminalDragData.ts
Normal file
62
application/state/terminalDragData.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
export const WORKSPACE_SESSION_DRAG_TYPE = 'application/x-netcatty-workspace-session';
|
||||||
|
|
||||||
|
type DataTransferLike = {
|
||||||
|
types: DOMStringList | readonly string[];
|
||||||
|
getData: (format: string) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function dataTransferHasType(dataTransfer: Pick<DataTransferLike, 'types'>, type: string): boolean {
|
||||||
|
return Array.prototype.includes.call(dataTransfer.types, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasWorkspaceSessionDrag(dataTransfer: Pick<DataTransferLike, 'types'>): boolean {
|
||||||
|
return dataTransferHasType(dataTransfer, WORKSPACE_SESSION_DRAG_TYPE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkspaceSessionDragId(dataTransfer: DataTransferLike): string {
|
||||||
|
return dataTransfer.getData(WORKSPACE_SESSION_DRAG_TYPE) || dataTransfer.getData('session-id');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPointInsideRect(
|
||||||
|
point: { clientX: number; clientY: number },
|
||||||
|
rect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>,
|
||||||
|
): boolean {
|
||||||
|
return point.clientX >= rect.left
|
||||||
|
&& point.clientX <= rect.right
|
||||||
|
&& point.clientY >= rect.top
|
||||||
|
&& point.clientY <= rect.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TopTabInsertionTarget = {
|
||||||
|
tabId: string;
|
||||||
|
position: 'before' | 'after';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getTopTabInsertionTarget(
|
||||||
|
point: { clientX: number; clientY: number },
|
||||||
|
topTabsRoot: HTMLElement | null,
|
||||||
|
): TopTabInsertionTarget | null {
|
||||||
|
if (!topTabsRoot || !isPointInsideRect(point, topTabsRoot.getBoundingClientRect())) return null;
|
||||||
|
|
||||||
|
const tabs = Array.from(topTabsRoot.querySelectorAll<HTMLElement>('[data-tab-id]'))
|
||||||
|
.filter((tab) => tab.dataset.tabType !== 'root');
|
||||||
|
|
||||||
|
if (tabs.length === 0) return null;
|
||||||
|
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const rect = tab.getBoundingClientRect();
|
||||||
|
const midpoint = rect.left + rect.width / 2;
|
||||||
|
const tabId = tab.dataset.tabId;
|
||||||
|
if (!tabId) continue;
|
||||||
|
if (point.clientX <= midpoint) {
|
||||||
|
return { tabId, position: 'before' };
|
||||||
|
}
|
||||||
|
if (point.clientX <= rect.right) {
|
||||||
|
return { tabId, position: 'after' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastTab = tabs[tabs.length - 1];
|
||||||
|
const lastTabId = lastTab?.dataset.tabId;
|
||||||
|
return lastTabId ? { tabId: lastTabId, position: 'after' } : null;
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ export const getAppLevelActions = (): Set<string> => {
|
|||||||
'nextTab',
|
'nextTab',
|
||||||
'prevTab',
|
'prevTab',
|
||||||
'closeTab',
|
'closeTab',
|
||||||
|
'closeSession',
|
||||||
'newTab',
|
'newTab',
|
||||||
'openHosts',
|
'openHosts',
|
||||||
'openSftp',
|
'openSftp',
|
||||||
@@ -35,6 +36,7 @@ export const getAppLevelActions = (): Set<string> => {
|
|||||||
'splitVertical',
|
'splitVertical',
|
||||||
'moveFocus',
|
'moveFocus',
|
||||||
'broadcast',
|
'broadcast',
|
||||||
|
'togglePaneZoom',
|
||||||
'openLocal',
|
'openLocal',
|
||||||
'openSettings',
|
'openSettings',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -17,8 +17,13 @@ SplitHint,
|
|||||||
updateWorkspaceSplitSizes,
|
updateWorkspaceSplitSizes,
|
||||||
} from '../../domain/workspace';
|
} from '../../domain/workspace';
|
||||||
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
|
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
|
||||||
import { buildOrderedWorkTabIds } from '../app/workTabSurface';
|
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
|
||||||
import { activeTabStore } from './activeTabStore';
|
import { activeTabStore } from './activeTabStore';
|
||||||
|
import {
|
||||||
|
closeSessionWorkspaceLayoutState,
|
||||||
|
detachSessionFromWorkspaceState,
|
||||||
|
replaceDissolvedWorkspaceTabOrder,
|
||||||
|
} from './sessionWorkspaceDetach';
|
||||||
import {
|
import {
|
||||||
createCopiedTerminalSessionClone,
|
createCopiedTerminalSessionClone,
|
||||||
createSplitTerminalSessionClone,
|
createSplitTerminalSessionClone,
|
||||||
@@ -122,33 +127,12 @@ export const useSessionState = () => {
|
|||||||
const wsId = targetSession?.workspaceId;
|
const wsId = targetSession?.workspaceId;
|
||||||
|
|
||||||
setWorkspaces(prevWorkspaces => {
|
setWorkspaces(prevWorkspaces => {
|
||||||
let removedWorkspaceId: string | null = null;
|
const {
|
||||||
let nextWorkspaces = prevWorkspaces;
|
workspaces: nextWorkspaces,
|
||||||
let dissolvedWorkspaceId: string | null = null;
|
removedWorkspaceId,
|
||||||
let lastRemainingSessionId: string | null = null;
|
dissolvedWorkspaceId,
|
||||||
|
lastRemainingSessionId,
|
||||||
if (wsId) {
|
} = closeSessionWorkspaceLayoutState(prevWorkspaces, wsId, sessionId);
|
||||||
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 remainingSessions = prevSessions.filter(s => s.id !== sessionId);
|
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
|
||||||
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
|
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
|
||||||
@@ -162,6 +146,14 @@ export const useSessionState = () => {
|
|||||||
return 'vault';
|
return 'vault';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (dissolvedWorkspaceId && lastRemainingSessionId) {
|
||||||
|
setTabOrder(prevTabOrder => replaceDissolvedWorkspaceTabOrder(
|
||||||
|
prevTabOrder,
|
||||||
|
dissolvedWorkspaceId,
|
||||||
|
[lastRemainingSessionId],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if (dissolvedWorkspaceId && currentActiveTabId === dissolvedWorkspaceId) {
|
if (dissolvedWorkspaceId && currentActiveTabId === dissolvedWorkspaceId) {
|
||||||
setActiveTabId(getFallback());
|
setActiveTabId(getFallback());
|
||||||
} else if (currentActiveTabId === sessionId) {
|
} else if (currentActiveTabId === sessionId) {
|
||||||
@@ -205,20 +197,39 @@ export const useSessionState = () => {
|
|||||||
const target = prevSessions.find(s => s.id === sessionId);
|
const target = prevSessions.find(s => s.id === sessionId);
|
||||||
if (target) {
|
if (target) {
|
||||||
setSessionRenameTarget(target);
|
setSessionRenameTarget(target);
|
||||||
setSessionRenameValue(target.hostLabel);
|
setSessionRenameValue(target.customName || target.hostLabel);
|
||||||
}
|
}
|
||||||
return prevSessions;
|
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 => {
|
setSessionRenameValue(prevValue => {
|
||||||
const name = prevValue.trim();
|
const trimmed = prevValue.trim();
|
||||||
if (!name) return prevValue;
|
if (!trimmed) return prevValue;
|
||||||
|
|
||||||
setSessionRenameTarget(prevTarget => {
|
setSessionRenameTarget(prevTarget => {
|
||||||
if (!prevTarget) return 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;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -888,6 +899,50 @@ export const useSessionState = () => {
|
|||||||
[getOrderedWorkTabs],
|
[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((
|
const reorderTabs = useCallback((
|
||||||
draggedId: string,
|
draggedId: string,
|
||||||
targetId: string,
|
targetId: string,
|
||||||
@@ -896,39 +951,13 @@ export const useSessionState = () => {
|
|||||||
) => {
|
) => {
|
||||||
if (draggedId === targetId) return;
|
if (draggedId === targetId) return;
|
||||||
|
|
||||||
setTabOrder(prevTabOrder => {
|
setTabOrder(prevTabOrder => reorderWorkTabIds(
|
||||||
const allTabIds = [...baseWorkTabIds, ...additionalTabIds];
|
prevTabOrder,
|
||||||
const allTabIdSet = new Set(allTabIds);
|
[...baseWorkTabIds, ...additionalTabIds],
|
||||||
|
draggedId,
|
||||||
// Build current effective order: existing order + new tabs at end
|
targetId,
|
||||||
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
|
position,
|
||||||
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;
|
|
||||||
});
|
|
||||||
}, [baseWorkTabIds]);
|
}, [baseWorkTabIds]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -942,6 +971,7 @@ export const useSessionState = () => {
|
|||||||
sessionRenameValue,
|
sessionRenameValue,
|
||||||
setSessionRenameValue,
|
setSessionRenameValue,
|
||||||
startSessionRename,
|
startSessionRename,
|
||||||
|
renameSessionInline,
|
||||||
submitSessionRename,
|
submitSessionRename,
|
||||||
resetSessionRename,
|
resetSessionRename,
|
||||||
workspaceRenameTarget,
|
workspaceRenameTarget,
|
||||||
@@ -962,6 +992,7 @@ export const useSessionState = () => {
|
|||||||
createWorkspaceFromTargets,
|
createWorkspaceFromTargets,
|
||||||
createWorkspaceFromSessions,
|
createWorkspaceFromSessions,
|
||||||
addSessionToWorkspace,
|
addSessionToWorkspace,
|
||||||
|
removeSessionFromWorkspace,
|
||||||
appendHostToWorkspace,
|
appendHostToWorkspace,
|
||||||
appendLocalTerminalToWorkspace,
|
appendLocalTerminalToWorkspace,
|
||||||
updateSplitSizes,
|
updateSplitSizes,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
|
|||||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||||
import { SearchAddon } from "@xterm/addon-search";
|
import { SearchAddon } from "@xterm/addon-search";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
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 React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useI18n } from "../application/i18n/I18nProvider";
|
import { useI18n } from "../application/i18n/I18nProvider";
|
||||||
import { detectLocalOs } from "../lib/localShell";
|
import { detectLocalOs } from "../lib/localShell";
|
||||||
@@ -149,8 +149,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
sessionLog,
|
sessionLog,
|
||||||
sshDebugLogEnabled,
|
sshDebugLogEnabled,
|
||||||
sudoAutofillPassword,
|
sudoAutofillPassword,
|
||||||
showSelectionAIAction,
|
showSelectionAIAction = true,
|
||||||
onAddSelectionToAI,
|
onAddSelectionToAI,
|
||||||
|
sessionDisplayName,
|
||||||
|
onRename,
|
||||||
|
onDetach,
|
||||||
|
onStartSessionDrag,
|
||||||
|
onEndSessionDrag,
|
||||||
|
onDetachPointerDown,
|
||||||
|
onDetachDragStart,
|
||||||
|
onDetachDragEnd,
|
||||||
}) => {
|
}) => {
|
||||||
const layoutSuppressActive = useTerminalLayoutSuppressActive();
|
const layoutSuppressActive = useTerminalLayoutSuppressActive();
|
||||||
const deferTerminalResize = isResizing || layoutSuppressActive;
|
const deferTerminalResize = isResizing || layoutSuppressActive;
|
||||||
@@ -1260,7 +1268,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
|
|
||||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
|
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
|
||||||
|
|
||||||
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
|
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionDisplayName, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Terminal = memo(TerminalComponent, terminalPropsAreEqual);
|
const Terminal = memo(TerminalComponent, terminalPropsAreEqual);
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
|||||||
onToggleWorkspaceViewMode,
|
onToggleWorkspaceViewMode,
|
||||||
onSetWorkspaceFocusedSession,
|
onSetWorkspaceFocusedSession,
|
||||||
onReorderWorkspaceSessions,
|
onReorderWorkspaceSessions,
|
||||||
|
onReorderTabs,
|
||||||
|
onCopySession,
|
||||||
|
onCopySessionToNewWindow,
|
||||||
onSplitSession,
|
onSplitSession,
|
||||||
onConnectToHost,
|
onConnectToHost,
|
||||||
onCreateLocalTerminal,
|
onCreateLocalTerminal,
|
||||||
@@ -150,6 +153,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
|||||||
showHostTreeSidebar = true,
|
showHostTreeSidebar = true,
|
||||||
toggleScriptsSidePanelRef,
|
toggleScriptsSidePanelRef,
|
||||||
toggleSidePanelRef,
|
toggleSidePanelRef,
|
||||||
|
// Session rename props
|
||||||
|
onStartSessionRename,
|
||||||
|
onSubmitSessionRename,
|
||||||
|
onRemoveSessionFromWorkspace,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const terminalRendererCwdBySessionRef = useRef<Map<string, string>>(new Map());
|
const terminalRendererCwdBySessionRef = useRef<Map<string, string>>(new Map());
|
||||||
@@ -1138,10 +1145,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
|||||||
onCreateWorkspaceFromSessions,
|
onCreateWorkspaceFromSessions,
|
||||||
onHotkeyAction,
|
onHotkeyAction,
|
||||||
onReorderWorkspaceSessions,
|
onReorderWorkspaceSessions,
|
||||||
|
onReorderTabs,
|
||||||
|
onCopySession,
|
||||||
|
onCopySessionToNewWindow,
|
||||||
onRequestAddToWorkspace,
|
onRequestAddToWorkspace,
|
||||||
onSessionData,
|
onSessionData,
|
||||||
onSetDraggingSessionId,
|
onSetDraggingSessionId,
|
||||||
onSetWorkspaceFocusedSession,
|
onSetWorkspaceFocusedSession,
|
||||||
|
onStartSessionRename,
|
||||||
|
onSubmitSessionRename,
|
||||||
|
onRemoveSessionFromWorkspace,
|
||||||
|
onStartSessionDrag: onSetDraggingSessionId,
|
||||||
|
onEndSessionDrag: () => onSetDraggingSessionId(null),
|
||||||
onSplitSession,
|
onSplitSession,
|
||||||
onSplitSessionRef,
|
onSplitSessionRef,
|
||||||
onToggleBroadcastRef,
|
onToggleBroadcastRef,
|
||||||
|
|||||||
@@ -18,13 +18,23 @@ Object.defineProperty(globalThis, "requestAnimationFrame", {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
computeHostTreeTabGutter,
|
computeHostTreeTabGutter,
|
||||||
|
resolveWorkspaceSessionTabDropTarget,
|
||||||
shouldKeepHostTreeToggleSurface,
|
shouldKeepHostTreeToggleSurface,
|
||||||
shouldShowHostTreeToggle,
|
shouldShowHostTreeToggle,
|
||||||
} = await import("./TopTabs.tsx");
|
} = 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 { activateLogViewTab } = await import("./top-tabs/TopTabItems.tsx");
|
||||||
const { activeTabStore } = await import("../application/state/activeTabStore.ts");
|
const { activeTabStore } = await import("../application/state/activeTabStore.ts");
|
||||||
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
|
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
|
||||||
const topTabsSource = readFileSync(new URL("./TopTabs.tsx", 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", () => {
|
test("host tree tab gutter fills the remaining sidebar width", () => {
|
||||||
assert.equal(computeHostTreeTabGutter(280, 120), 160);
|
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"/);
|
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", () => {
|
test("host tree chrome enters after theme switch settles so root labels can animate", () => {
|
||||||
assert.match(topTabsSource, /hostTreeChromeReady/);
|
assert.match(topTabsSource, /hostTreeChromeReady/);
|
||||||
assert.match(topTabsSource, /scheduleAfterInstantThemeSwitch\(\(\) => \{\s*cancelHostTreeChromeReadyRef\.current = null;\s*setHostTreeChromeReady\(true\);/);
|
assert.match(topTabsSource, /scheduleAfterInstantThemeSwitch\(\(\) => \{\s*cancelHostTreeChromeReadyRef\.current = null;\s*setHostTreeChromeReady\(true\);/);
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { fromEditorTabId, isEditorTabId, useActiveTabId } from '../application/s
|
|||||||
import { isHostTreeWorkTabSurface } from '../application/app/workTabSurface';
|
import { isHostTreeWorkTabSurface } from '../application/app/workTabSurface';
|
||||||
import type { EditorTab } from '../application/state/editorTabStore';
|
import type { EditorTab } from '../application/state/editorTabStore';
|
||||||
import { buildWorkspaceActivityMap } from '../application/state/sessionActivity';
|
import { buildWorkspaceActivityMap } from '../application/state/sessionActivity';
|
||||||
|
import { collectSessionIds } from '../domain/workspace';
|
||||||
import { useSessionActivityMap } from '../application/state/sessionActivityStore';
|
import { useSessionActivityMap } from '../application/state/sessionActivityStore';
|
||||||
|
import { getTopTabInsertionTarget, getWorkspaceSessionDragId, hasWorkspaceSessionDrag } from '../application/state/terminalDragData';
|
||||||
import {
|
import {
|
||||||
useTerminalHostTreeLayoutWidth,
|
useTerminalHostTreeLayoutWidth,
|
||||||
useTerminalHostTreeOpen,
|
useTerminalHostTreeOpen,
|
||||||
@@ -82,6 +84,34 @@ export function shouldKeepHostTreeToggleSurface({
|
|||||||
return enabled && activeWorkTabCount > 0;
|
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 {
|
interface TopTabsProps {
|
||||||
theme: 'dark' | 'light';
|
theme: 'dark' | 'light';
|
||||||
hosts: Host[];
|
hosts: Host[];
|
||||||
@@ -109,6 +139,10 @@ interface TopTabsProps {
|
|||||||
onStartSessionDrag: (sessionId: string) => void;
|
onStartSessionDrag: (sessionId: string) => void;
|
||||||
onEndSessionDrag: () => void;
|
onEndSessionDrag: () => void;
|
||||||
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => 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;
|
showSftpTab: boolean;
|
||||||
showHostTreeSidebar: boolean;
|
showHostTreeSidebar: boolean;
|
||||||
editorTabs: readonly EditorTab[];
|
editorTabs: readonly EditorTab[];
|
||||||
@@ -143,6 +177,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
|||||||
onStartSessionDrag,
|
onStartSessionDrag,
|
||||||
onEndSessionDrag,
|
onEndSessionDrag,
|
||||||
onReorderTabs,
|
onReorderTabs,
|
||||||
|
onRemoveSessionFromWorkspace,
|
||||||
showSftpTab,
|
showSftpTab,
|
||||||
showHostTreeSidebar,
|
showHostTreeSidebar,
|
||||||
editorTabs,
|
editorTabs,
|
||||||
@@ -386,7 +421,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const syncGutter = () => updateHostTreeTabGutterRef.current();
|
const syncGutter = () => updateHostTreeTabGutterRef.current();
|
||||||
syncGutter({ deferClose: true });
|
updateHostTreeTabGutterRef.current({ deferClose: true });
|
||||||
const rafId = window.requestAnimationFrame(() => syncGutter());
|
const rafId = window.requestAnimationFrame(() => syncGutter());
|
||||||
const settleTimer = window.setTimeout(syncGutter, 320);
|
const settleTimer = window.setTimeout(syncGutter, 320);
|
||||||
const root = tabsContainerRef.current?.closest('[data-top-tabs-root]') as HTMLElement | null;
|
const root = tabsContainerRef.current?.closest('[data-top-tabs-root]') as HTMLElement | null;
|
||||||
@@ -442,6 +477,11 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
|
||||||
|
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
|
||||||
|
setDropIndicator(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!draggedTabIdRef.current || draggedTabIdRef.current === tabId) {
|
if (!draggedTabIdRef.current || draggedTabIdRef.current === tabId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -463,6 +503,26 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
|||||||
|
|
||||||
const handleTabDrop = useCallback((e: React.DragEvent, targetTabId: string) => {
|
const handleTabDrop = useCallback((e: React.DragEvent, targetTabId: string) => {
|
||||||
e.preventDefault();
|
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;
|
const draggedId = e.dataTransfer.getData('tab-reorder-id') || draggedTabIdRef.current;
|
||||||
|
|
||||||
if (draggedId && draggedId !== targetTabId && dropIndicator) {
|
if (draggedId && draggedId !== targetTabId && dropIndicator) {
|
||||||
@@ -471,7 +531,33 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
|||||||
|
|
||||||
setDropIndicator(null);
|
setDropIndicator(null);
|
||||||
setIsDraggingForReorder(false);
|
setIsDraggingForReorder(false);
|
||||||
}, [dropIndicator, onReorderTabs]);
|
}, [dropIndicator, onEndSessionDrag, onRemoveSessionFromWorkspace, onReorderTabs, sessions, workspaces]);
|
||||||
|
|
||||||
|
const handleTabBarDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
if (!hasWorkspaceSessionDrag(e.dataTransfer)) return;
|
||||||
|
const draggedSessionId = getWorkspaceSessionDragId(e.dataTransfer);
|
||||||
|
if (!draggedSessionId) return;
|
||||||
|
const draggedSession = sessions.find((s) => s.id === draggedSessionId);
|
||||||
|
if (!draggedSession?.workspaceId) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const root = e.currentTarget.closest('[data-top-tabs-root]') as HTMLElement | null;
|
||||||
|
const insertionTarget = getTopTabInsertionTarget(e, root);
|
||||||
|
onRemoveSessionFromWorkspace(
|
||||||
|
draggedSessionId,
|
||||||
|
insertionTarget
|
||||||
|
? resolveWorkspaceSessionTabDropTarget({
|
||||||
|
targetTabId: insertionTarget.tabId,
|
||||||
|
position: insertionTarget.position,
|
||||||
|
draggedSessionId,
|
||||||
|
draggedWorkspaceId: draggedSession.workspaceId,
|
||||||
|
workspaces,
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
setDropIndicator(null);
|
||||||
|
setIsDraggingForReorder(false);
|
||||||
|
onEndSessionDrag();
|
||||||
|
}, [onEndSessionDrag, onRemoveSessionFromWorkspace, sessions, workspaces]);
|
||||||
|
|
||||||
const handleScrollableTabClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
const handleScrollableTabClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
@@ -682,6 +768,14 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
|||||||
const shiftStyle = tabShiftStyles[workspace.id] || emptyTabStyle;
|
const shiftStyle = tabShiftStyles[workspace.id] || emptyTabStyle;
|
||||||
const showDropIndicatorBefore = dropIndicator?.tabId === workspace.id && dropIndicator.position === 'before';
|
const showDropIndicatorBefore = dropIndicator?.tabId === workspace.id && dropIndicator.position === 'before';
|
||||||
const showDropIndicatorAfter = dropIndicator?.tabId === workspace.id && dropIndicator.position === 'after';
|
const showDropIndicatorAfter = dropIndicator?.tabId === workspace.id && dropIndicator.position === 'after';
|
||||||
|
const workspaceSessionIds = collectSessionIds(workspace.root);
|
||||||
|
const workspaceSessionLabels: Record<string, string> = {};
|
||||||
|
for (const sessionId of workspaceSessionIds) {
|
||||||
|
const wsSession = sessions.find((s) => s.id === sessionId);
|
||||||
|
if (wsSession) {
|
||||||
|
workspaceSessionLabels[sessionId] = wsSession.customName || wsSession.hostLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceTopTab
|
<WorkspaceTopTab
|
||||||
@@ -701,6 +795,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
|||||||
onTabDrop={handleTabDrop}
|
onTabDrop={handleTabDrop}
|
||||||
onRenameWorkspace={onRenameWorkspace}
|
onRenameWorkspace={onRenameWorkspace}
|
||||||
onCloseWorkspace={onCloseWorkspace}
|
onCloseWorkspace={onCloseWorkspace}
|
||||||
|
onDetachSessionFromWorkspace={(_workspaceId, sessionId) => onRemoveSessionFromWorkspace(sessionId)}
|
||||||
|
workspaceSessionLabels={workspaceSessionLabels}
|
||||||
renderBulkCloseItems={renderBulkCloseItems}
|
renderBulkCloseItems={renderBulkCloseItems}
|
||||||
t={t}
|
t={t}
|
||||||
tabAnimationClass={getTabAnimationClass(workspace.id)}
|
tabAnimationClass={getTabAnimationClass(workspace.id)}
|
||||||
@@ -801,12 +897,18 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
|||||||
style={dragRegionStyle}
|
style={dragRegionStyle}
|
||||||
// Add container-level drag handlers to prevent indicator loss
|
// Add container-level drag handlers to prevent indicator loss
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
|
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Keep drop indicator active while dragging over the container
|
// Keep drop indicator active while dragging over the container
|
||||||
if (draggedTabIdRef.current && isDraggingForReorder && !dropIndicator) {
|
if (draggedTabIdRef.current && isDraggingForReorder && !dropIndicator) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = 'move';
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onDrop={handleTabBarDrop}
|
||||||
>
|
>
|
||||||
{hasHostTreeToggleSurface && (
|
{hasHostTreeToggleSurface && (
|
||||||
<div
|
<div
|
||||||
@@ -871,6 +973,13 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
|||||||
className="flex items-end gap-0 overflow-x-auto scrollbar-none app-drag max-w-full"
|
className="flex items-end gap-0 overflow-x-auto scrollbar-none app-drag max-w-full"
|
||||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||||
onClick={handleScrollableTabClick}
|
onClick={handleScrollableTabClick}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDrop={handleTabBarDrop}
|
||||||
>
|
>
|
||||||
{renderOrderedTabs()}
|
{renderOrderedTabs()}
|
||||||
{/* Add new tab button - follows last tab when not overflowing */}
|
{/* Add new tab button - follows last tab when not overflowing */}
|
||||||
|
|||||||
81
components/terminal/SessionInlineRenameInput.tsx
Normal file
81
components/terminal/SessionInlineRenameInput.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
type SessionInlineRenameInputProps = {
|
||||||
|
initialName: string;
|
||||||
|
onCommit: (name: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SessionInlineRenameInput: React.FC<SessionInlineRenameInputProps> = ({
|
||||||
|
initialName,
|
||||||
|
onCommit,
|
||||||
|
onCancel,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [value, setValue] = useState(initialName);
|
||||||
|
const committedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (!input) return;
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
if (committedRef.current) return;
|
||||||
|
committedRef.current = true;
|
||||||
|
onCommit(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
if (committedRef.current) return;
|
||||||
|
committedRef.current = true;
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
data-session-inline-rename="true"
|
||||||
|
value={value}
|
||||||
|
draggable={false}
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
commit();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
commit();
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 flex-1 truncate select-text rounded-sm border border-primary/50 bg-background/80 px-1 py-0 text-sm font-medium outline-none ring-1 ring-primary/30',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
|
Pencil,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
SquareArrowOutUpRight,
|
||||||
SplitSquareHorizontal,
|
SplitSquareHorizontal,
|
||||||
SplitSquareVertical,
|
SplitSquareVertical,
|
||||||
Terminal as TerminalIcon,
|
Terminal as TerminalIcon,
|
||||||
@@ -48,6 +50,8 @@ export interface TerminalContextMenuProps {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onSelectWord?: () => void;
|
onSelectWord?: () => void;
|
||||||
onAddSelectionToAI?: () => void;
|
onAddSelectionToAI?: () => void;
|
||||||
|
onRename?: () => void;
|
||||||
|
onDetach?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const shouldShowReconnectAction = ({
|
export const shouldShowReconnectAction = ({
|
||||||
@@ -125,6 +129,8 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
onSelectWord,
|
onSelectWord,
|
||||||
onAddSelectionToAI,
|
onAddSelectionToAI,
|
||||||
|
onRename,
|
||||||
|
onDetach,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const isMac = hotkeyScheme === 'mac';
|
const isMac = hotkeyScheme === 'mac';
|
||||||
@@ -299,6 +305,26 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
|||||||
<ContextMenuShortcut>{clearShortcut}</ContextMenuShortcut>
|
<ContextMenuShortcut>{clearShortcut}</ContextMenuShortcut>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
|
||||||
|
{onRename && (
|
||||||
|
<>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem onClick={onRename}>
|
||||||
|
<Pencil size={14} className="mr-2" />
|
||||||
|
{t('terminal.menu.rename')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onDetach && (
|
||||||
|
<>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem onClick={onDetach}>
|
||||||
|
<SquareArrowOutUpRight size={14} className="mr-2" />
|
||||||
|
{t('terminal.menu.detach')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<>
|
<>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
|
|||||||
@@ -47,16 +47,16 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
|||||||
if (!enabled || !isConnected || !serverStats.lastUpdated) return null;
|
if (!enabled || !isConnected || !serverStats.lastUpdated) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2.5 ml-2 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0">
|
<div className="terminal-server-stats flex items-center gap-2 ml-1 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0 shrink">
|
||||||
{/* CPU with HoverCard for per-core details */}
|
{/* CPU with HoverCard for per-core details */}
|
||||||
<HoverCard openDelay={200} closeDelay={100}>
|
<HoverCard openDelay={200} closeDelay={100}>
|
||||||
<HoverCardTrigger asChild>
|
<HoverCardTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
|
||||||
aria-label={t("terminal.serverStats.cpu")}
|
aria-label={t("terminal.serverStats.cpu")}
|
||||||
>
|
>
|
||||||
<Cpu size={10} className="flex-shrink-0" />
|
<Cpu size={10} className="flex-shrink-0" />
|
||||||
<span>
|
<span className="truncate">
|
||||||
{serverStats.cpu !== null ? `${serverStats.cpu}%` : '--'}
|
{serverStats.cpu !== null ? `${serverStats.cpu}%` : '--'}
|
||||||
{serverStats.cpuCores !== null && ` (${serverStats.cpuCores}C)`}
|
{serverStats.cpuCores !== null && ` (${serverStats.cpuCores}C)`}
|
||||||
</span>
|
</span>
|
||||||
@@ -121,11 +121,11 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
|||||||
<HoverCard openDelay={200} closeDelay={100}>
|
<HoverCard openDelay={200} closeDelay={100}>
|
||||||
<HoverCardTrigger asChild>
|
<HoverCardTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
|
||||||
aria-label={t("terminal.serverStats.memory")}
|
aria-label={t("terminal.serverStats.memory")}
|
||||||
>
|
>
|
||||||
<MemoryStick size={10} className="flex-shrink-0" />
|
<MemoryStick size={10} className="flex-shrink-0" />
|
||||||
<span>
|
<span className="truncate">
|
||||||
{serverStats.memUsed !== null && serverStats.memTotal !== null
|
{serverStats.memUsed !== null && serverStats.memTotal !== null
|
||||||
? `${(serverStats.memUsed / 1024).toFixed(1)}/${(serverStats.memTotal / 1024).toFixed(1)}G`
|
? `${(serverStats.memUsed / 1024).toFixed(1)}/${(serverStats.memTotal / 1024).toFixed(1)}G`
|
||||||
: '--'}
|
: '--'}
|
||||||
@@ -248,11 +248,12 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
|||||||
<HoverCard openDelay={200} closeDelay={100}>
|
<HoverCard openDelay={200} closeDelay={100}>
|
||||||
<HoverCardTrigger asChild>
|
<HoverCardTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
|
||||||
aria-label={t("terminal.serverStats.disk")}
|
aria-label={t("terminal.serverStats.disk")}
|
||||||
>
|
>
|
||||||
<HardDrive size={10} className="flex-shrink-0" />
|
<HardDrive size={10} className="flex-shrink-0" />
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
|
"truncate",
|
||||||
serverStats.diskPercent !== null && serverStats.diskPercent >= 90 && "text-red-400",
|
serverStats.diskPercent !== null && serverStats.diskPercent >= 90 && "text-red-400",
|
||||||
serverStats.diskPercent !== null && serverStats.diskPercent >= 80 && serverStats.diskPercent < 90 && "text-amber-400"
|
serverStats.diskPercent !== null && serverStats.diskPercent >= 80 && serverStats.diskPercent < 90 && "text-amber-400"
|
||||||
)}>
|
)}>
|
||||||
@@ -315,13 +316,13 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
|||||||
<HoverCard openDelay={200} closeDelay={100}>
|
<HoverCard openDelay={200} closeDelay={100}>
|
||||||
<HoverCardTrigger asChild>
|
<HoverCardTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
|
className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
|
||||||
aria-label={t("terminal.serverStats.network")}
|
aria-label={t("terminal.serverStats.network")}
|
||||||
>
|
>
|
||||||
<ArrowDownToLine size={9} className="flex-shrink-0 text-emerald-400" />
|
<ArrowDownToLine size={9} className="flex-shrink-0 text-emerald-400" />
|
||||||
<span>{formatNetSpeed(serverStats.netRxSpeed)}</span>
|
<span className="truncate">{formatNetSpeed(serverStats.netRxSpeed)}</span>
|
||||||
<ArrowUpFromLine size={9} className="flex-shrink-0 text-sky-400" />
|
<ArrowUpFromLine size={9} className="flex-shrink-0 text-sky-400" />
|
||||||
<span>{formatNetSpeed(serverStats.netTxSpeed)}</span>
|
<span className="truncate">{formatNetSpeed(serverStats.netTxSpeed)}</span>
|
||||||
</button>
|
</button>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent
|
<HoverCardContent
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function terminalViewCtxEqual(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||||
const { Activity, Button, Clock3, Copy, Maximize2, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
|
const { Activity, Button, Clock3, Copy, Maximize2, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, searchMatchCount, selectionOverlayPosition, sessionDisplayName, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
|
||||||
const ymodemActionEnabled = shouldEnableYmodemAction({
|
const ymodemActionEnabled = shouldEnableYmodemAction({
|
||||||
isSerialConnection,
|
isSerialConnection,
|
||||||
status,
|
status,
|
||||||
@@ -133,6 +133,8 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
|||||||
onReconnect={handleRetry}
|
onReconnect={handleRetry}
|
||||||
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
|
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
|
||||||
onAddSelectionToAI={ctx.onAddSelectionToAI ? handleAddSelectionToAI : undefined}
|
onAddSelectionToAI={ctx.onAddSelectionToAI ? handleAddSelectionToAI : undefined}
|
||||||
|
onRename={onRename}
|
||||||
|
onDetach={inWorkspace ? onDetach : undefined}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -170,7 +172,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
|||||||
)}
|
)}
|
||||||
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
|
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
|
className="terminal-topbar flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
|
||||||
onMouseDownCapture={handleTopOverlayMouseDownCapture}
|
onMouseDownCapture={handleTopOverlayMouseDownCapture}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--terminal-ui-bg)',
|
backgroundColor: 'var(--terminal-ui-bg)',
|
||||||
@@ -183,14 +185,27 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
|||||||
['--terminal-toolbar-btn-active' as never]: 'var(--terminal-ui-toolbar-btn-active)',
|
['--terminal-toolbar-btn-active' as never]: 'var(--terminal-ui-toolbar-btn-active)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 text-[11px] font-semibold min-w-0">
|
<div
|
||||||
<span className="whitespace-nowrap truncate">{host.label}</span>
|
className={cn(
|
||||||
<span
|
"terminal-title-cluster flex items-center gap-1 text-[11px] font-semibold min-w-0 overflow-hidden shrink",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-block h-2 w-2 rounded-full flex-shrink-0",
|
"flex items-center gap-1 min-w-0",
|
||||||
statusDotTone,
|
inWorkspace && onDetachPointerDown && "cursor-grab active:cursor-grabbing",
|
||||||
)}
|
)}
|
||||||
/>
|
data-terminal-detach-drag-handle={inWorkspace && onDetachPointerDown ? "true" : undefined}
|
||||||
|
onPointerDown={onDetachPointerDown}
|
||||||
|
>
|
||||||
|
<span className="whitespace-nowrap truncate min-w-0 max-w-[12rem]">{sessionDisplayName || host.label}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-2 w-2 rounded-full flex-shrink-0",
|
||||||
|
statusDotTone,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{shouldShowLineTimestampToolbarToggle(lineTimestampsAvailable, onUpdateHost) && (
|
{shouldShowLineTimestampToolbarToggle(lineTimestampsAvailable, onUpdateHost) && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -266,7 +281,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
|||||||
isVisible={isVisible}
|
isVisible={isVisible}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1 min-w-0" />
|
||||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||||
{inWorkspace && onToggleBroadcast && (
|
{inWorkspace && onToggleBroadcast && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -296,6 +311,22 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{inWorkspace && onDetach && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
|
||||||
|
onClick={onDetach}
|
||||||
|
aria-label={t('terminal.toolbar.detach')}
|
||||||
|
>
|
||||||
|
<SquareArrowOutUpRight size={12} />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">{t('terminal.toolbar.detach')}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{inWorkspace && !isFocusMode && onExpandToFocus && (
|
{inWorkspace && !isFocusMode && onExpandToFocus && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DragEvent, PointerEvent } from "react";
|
||||||
import { Terminal as XTerm } from "@xterm/xterm";
|
import { Terminal as XTerm } from "@xterm/xterm";
|
||||||
|
|
||||||
import { logger } from "../../lib/logger";
|
import { logger } from "../../lib/logger";
|
||||||
@@ -17,6 +18,14 @@ import type {
|
|||||||
|
|
||||||
export const MAX_CONNECTION_LOG_DATA_CHARS = 1_000_000;
|
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.
|
* 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.
|
* For nested files, extracts the root folder path; for single files, uses the full path.
|
||||||
@@ -170,6 +179,17 @@ export interface TerminalProps {
|
|||||||
sudoAutofillPassword?: string;
|
sudoAutofillPassword?: string;
|
||||||
showSelectionAIAction?: boolean;
|
showSelectionAIAction?: boolean;
|
||||||
onAddSelectionToAI?: (sessionId: string, selection: string) => void;
|
onAddSelectionToAI?: (sessionId: string, selection: string) => void;
|
||||||
|
/** Override display name for the pane title bar (customName || hostLabel) */
|
||||||
|
sessionDisplayName?: string;
|
||||||
|
/** Open rename dialog for this session */
|
||||||
|
onRename?: () => void;
|
||||||
|
/** Detach this session from its workspace to a standalone tab */
|
||||||
|
onDetach?: () => void;
|
||||||
|
onStartSessionDrag?: (sessionId: string) => void;
|
||||||
|
onEndSessionDrag?: () => void;
|
||||||
|
onDetachPointerDown?: (e: PointerEvent<HTMLElement>) => void;
|
||||||
|
onDetachDragStart?: (e: DragEvent) => void;
|
||||||
|
onDetachDragEnd?: (e: DragEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatNetSpeed(bytesPerSec: number): string {
|
export function formatNetSpeed(bytesPerSec: number): string {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const terminalPropsAreEqual = (
|
|||||||
&& prev.customAccent === next.customAccent
|
&& prev.customAccent === next.customAccent
|
||||||
&& prev.terminalSettings === next.terminalSettings
|
&& prev.terminalSettings === next.terminalSettings
|
||||||
&& prev.sessionId === next.sessionId
|
&& prev.sessionId === next.sessionId
|
||||||
|
&& prev.sessionDisplayName === next.sessionDisplayName
|
||||||
&& prev.startupCommand === next.startupCommand
|
&& prev.startupCommand === next.startupCommand
|
||||||
&& prev.noAutoRun === next.noAutoRun
|
&& prev.noAutoRun === next.noAutoRun
|
||||||
&& prev.reuseConnectionFromSessionId === next.reuseConnectionFromSessionId
|
&& prev.reuseConnectionFromSessionId === next.reuseConnectionFromSessionId
|
||||||
@@ -71,4 +72,11 @@ export const terminalPropsAreEqual = (
|
|||||||
&& prev.onBroadcastInput === next.onBroadcastInput
|
&& prev.onBroadcastInput === next.onBroadcastInput
|
||||||
&& prev.onSnippetExecutorChange === next.onSnippetExecutorChange
|
&& prev.onSnippetExecutorChange === next.onSnippetExecutorChange
|
||||||
&& prev.onAddSelectionToAI === next.onAddSelectionToAI
|
&& prev.onAddSelectionToAI === next.onAddSelectionToAI
|
||||||
|
&& prev.onRename === next.onRename
|
||||||
|
&& prev.onDetach === next.onDetach
|
||||||
|
&& prev.onStartSessionDrag === next.onStartSessionDrag
|
||||||
|
&& prev.onEndSessionDrag === next.onEndSessionDrag
|
||||||
|
&& prev.onDetachPointerDown === next.onDetachPointerDown
|
||||||
|
&& prev.onDetachDragStart === next.onDetachDragStart
|
||||||
|
&& prev.onDetachDragEnd === next.onDetachDragEnd
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,3 +62,18 @@ test("allows native focus for contenteditable regions", () => {
|
|||||||
|
|
||||||
assert.equal(shouldPreserveTerminalFocusOnMouseDown(editableTarget as unknown as EventTarget), false);
|
assert.equal(shouldPreserveTerminalFocusOnMouseDown(editableTarget as unknown as EventTarget), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("allows native drag start from the terminal detach drag handle", () => {
|
||||||
|
const dragHandleTarget = {
|
||||||
|
tagName: "span",
|
||||||
|
isContentEditable: false,
|
||||||
|
closest(selector: string) {
|
||||||
|
return selector.includes("data-terminal-detach-drag-handle") ? { tagName: "DIV" } : null;
|
||||||
|
},
|
||||||
|
getAttribute() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(shouldPreserveTerminalFocusOnMouseDown(dragHandleTarget as unknown as EventTarget), false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ type FocusTargetLike = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const EDITABLE_SELECTOR = 'input, textarea, select, [contenteditable=""], [contenteditable="true"], [role="textbox"]';
|
const 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
|
* 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") {
|
if (typeof candidate.getAttribute === "function") {
|
||||||
const contentEditable = candidate.getAttribute("contenteditable");
|
const contentEditable = candidate.getAttribute("contenteditable");
|
||||||
const role = candidate.getAttribute("role");
|
const role = candidate.getAttribute("role");
|
||||||
|
const detachDragHandle = candidate.getAttribute("data-terminal-detach-drag-handle");
|
||||||
if (contentEditable === "" || contentEditable === "true" || role === "textbox") {
|
if (contentEditable === "" || contentEditable === "true" || role === "textbox") {
|
||||||
return false;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import { STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH } from '../../infrastructure/
|
|||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||||
import { DistroAvatar } from '../DistroAvatar';
|
import { DistroAvatar } from '../DistroAvatar';
|
||||||
|
import { SessionInlineRenameInput } from '../terminal/SessionInlineRenameInput';
|
||||||
|
import { SessionTabContextMenuContent } from '../top-tabs/SessionTabContextMenuContent';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
|
import { ContextMenu, ContextMenuTrigger } from '../ui/context-menu';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import { ScrollArea } from '../ui/scroll-area';
|
import { ScrollArea } from '../ui/scroll-area';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||||
@@ -17,8 +20,13 @@ interface TerminalFocusSidebarProps {
|
|||||||
focusedSessionId: string | undefined;
|
focusedSessionId: string | undefined;
|
||||||
onReorderWorkspaceSessions?: (workspaceId: string, draggedSessionId: string, targetSessionId: string, position: 'before' | 'after') => void;
|
onReorderWorkspaceSessions?: (workspaceId: string, draggedSessionId: string, targetSessionId: string, position: 'before' | 'after') => void;
|
||||||
onRequestAddToWorkspace?: (workspaceId: string) => 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;
|
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
|
||||||
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
|
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
|
||||||
|
onSubmitSessionRename: (sessionId: string, name: string) => void;
|
||||||
resolvedPreviewTheme: TerminalTheme;
|
resolvedPreviewTheme: TerminalTheme;
|
||||||
sessionHostsMap: Map<string, Host>;
|
sessionHostsMap: Map<string, Host>;
|
||||||
sessions: TerminalSession[];
|
sessions: TerminalSession[];
|
||||||
@@ -40,6 +48,15 @@ type WorkspaceFocusSessionRowProps = {
|
|||||||
session: TerminalSession;
|
session: TerminalSession;
|
||||||
host: Host | undefined;
|
host: Host | undefined;
|
||||||
isSelected: boolean;
|
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;
|
isDragging: boolean;
|
||||||
dropPosition: 'before' | 'after' | null;
|
dropPosition: 'before' | 'after' | null;
|
||||||
theme: FocusSidebarTheme;
|
theme: FocusSidebarTheme;
|
||||||
@@ -48,12 +65,22 @@ type WorkspaceFocusSessionRowProps = {
|
|||||||
onDragOver: (event: DragEvent, sessionId: string) => void;
|
onDragOver: (event: DragEvent, sessionId: string) => void;
|
||||||
onDrop: (event: DragEvent, sessionId: string) => void;
|
onDrop: (event: DragEvent, sessionId: string) => void;
|
||||||
onDragEnd: () => void;
|
onDragEnd: () => void;
|
||||||
|
t: (key: string) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
|
const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
|
||||||
session,
|
session,
|
||||||
host,
|
host,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
isRenaming,
|
||||||
|
renameValue,
|
||||||
|
onStartRename,
|
||||||
|
onSubmitRename,
|
||||||
|
onCancelRename,
|
||||||
|
onCloseSession,
|
||||||
|
onCopySession,
|
||||||
|
onCopySessionToNewWindow,
|
||||||
|
onDetachSessionFromWorkspace,
|
||||||
isDragging,
|
isDragging,
|
||||||
dropPosition,
|
dropPosition,
|
||||||
theme,
|
theme,
|
||||||
@@ -62,6 +89,7 @@ const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
|
|||||||
onDragOver,
|
onDragOver,
|
||||||
onDrop,
|
onDrop,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
|
t,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
termFg,
|
termFg,
|
||||||
@@ -83,80 +111,121 @@ const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
|
|||||||
const rowFg = isSelected ? termFg : unselectedFg;
|
const rowFg = isSelected ? termFg : unselectedFg;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ContextMenu>
|
||||||
data-workspace-focus-session-id={session.id}
|
<ContextMenuTrigger asChild>
|
||||||
draggable
|
<div
|
||||||
role="button"
|
data-workspace-focus-session-id={session.id}
|
||||||
tabIndex={0}
|
draggable
|
||||||
className={cn(
|
role="button"
|
||||||
'relative flex w-full select-none items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm font-normal outline-none transition-colors hover:text-inherit focus-visible:ring-1',
|
tabIndex={0}
|
||||||
isDragging && 'opacity-50',
|
className={cn(
|
||||||
)}
|
'relative flex w-full select-none items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm font-normal outline-none transition-colors hover:text-inherit focus-visible:ring-1',
|
||||||
style={{
|
isDragging && 'opacity-50',
|
||||||
backgroundColor: restBg,
|
)}
|
||||||
color: rowFg,
|
style={{
|
||||||
boxShadow: dropPosition
|
backgroundColor: restBg,
|
||||||
? `inset 0 ${dropPosition === 'before' ? '2px' : '-2px'} 0 ${termFg}`
|
color: rowFg,
|
||||||
: undefined,
|
boxShadow: dropPosition
|
||||||
}}
|
? `inset 0 ${dropPosition === 'before' ? '2px' : '-2px'} 0 ${termFg}`
|
||||||
onDragStart={(event) => onDragStart(event, session.id)}
|
: undefined,
|
||||||
onDragOver={(event) => onDragOver(event, session.id)}
|
}}
|
||||||
onDragLeave={(event) => {
|
onContextMenu={() => onSelect(session.id)}
|
||||||
event.stopPropagation();
|
onDragStart={(event) => onDragStart(event, session.id)}
|
||||||
}}
|
onDragOver={(event) => onDragOver(event, session.id)}
|
||||||
onDrop={(event) => onDrop(event, session.id)}
|
onDragLeave={(event) => {
|
||||||
onDragEnd={onDragEnd}
|
event.stopPropagation();
|
||||||
onMouseEnter={(event) => {
|
}}
|
||||||
event.currentTarget.style.backgroundColor = hoverBg;
|
onDrop={(event) => onDrop(event, session.id)}
|
||||||
}}
|
onDragEnd={onDragEnd}
|
||||||
onMouseLeave={(event) => {
|
onMouseEnter={(event) => {
|
||||||
event.currentTarget.style.backgroundColor = restBg;
|
event.currentTarget.style.backgroundColor = hoverBg;
|
||||||
}}
|
}}
|
||||||
onClick={() => onSelect(session.id)}
|
onMouseLeave={(event) => {
|
||||||
onKeyDown={(event) => {
|
event.currentTarget.style.backgroundColor = restBg;
|
||||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
}}
|
||||||
event.preventDefault();
|
onClick={() => onSelect(session.id)}
|
||||||
onSelect(session.id);
|
onKeyDown={(event) => {
|
||||||
}}
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
>
|
event.preventDefault();
|
||||||
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center self-center">
|
onSelect(session.id);
|
||||||
{host ? (
|
}}
|
||||||
<DistroAvatar
|
>
|
||||||
host={host}
|
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center self-center">
|
||||||
fallback={session.hostLabel}
|
{host ? (
|
||||||
size="sm"
|
<DistroAvatar
|
||||||
className="!h-6 !w-6"
|
host={host}
|
||||||
/>
|
fallback={session.hostLabel}
|
||||||
) : (
|
size="sm"
|
||||||
<Server size={14} style={{ color: mutedFg }} />
|
className="!h-6 !w-6"
|
||||||
)}
|
/>
|
||||||
<Circle
|
) : (
|
||||||
size={5}
|
<Server size={14} style={{ color: mutedFg }} />
|
||||||
className={cn('absolute bottom-0 right-0 fill-current', statusColor)}
|
)}
|
||||||
/>
|
<Circle
|
||||||
</div>
|
size={5}
|
||||||
<div className="flex h-6 flex-1 min-w-0 flex-col justify-center self-center text-left">
|
className={cn('absolute bottom-0 right-0 fill-current', statusColor)}
|
||||||
<div className={cn('truncate text-xs leading-none', isSelected ? 'font-semibold' : 'font-medium')}>
|
/>
|
||||||
{session.hostLabel}
|
</div>
|
||||||
|
<div className="flex h-6 flex-1 min-w-0 flex-col justify-center self-center text-left">
|
||||||
|
{isRenaming ? (
|
||||||
|
<SessionInlineRenameInput
|
||||||
|
initialName={renameValue}
|
||||||
|
onCommit={onSubmitRename}
|
||||||
|
onCancel={onCancelRename}
|
||||||
|
className="h-5 text-xs leading-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn('truncate text-xs leading-none', isSelected ? 'font-semibold' : 'font-medium')}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onStartRename(session.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session.customName || session.hostLabel}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 truncate text-[10px] leading-none" style={{ color: mutedFg }}>
|
||||||
|
{session.username}@{session.hostname}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 truncate text-[10px] leading-none" style={{ color: mutedFg }}>
|
</ContextMenuTrigger>
|
||||||
{session.username}@{session.hostname}
|
<SessionTabContextMenuContent
|
||||||
</div>
|
sessionId={session.id}
|
||||||
</div>
|
onCloseSession={onCloseSession}
|
||||||
</div>
|
onCopySession={onCopySession}
|
||||||
|
onCopySessionToNewWindow={onCopySessionToNewWindow}
|
||||||
|
onDetachSession={onDetachSessionFromWorkspace}
|
||||||
|
onRenameSession={onStartRename}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
);
|
);
|
||||||
}, (prev, next) => (
|
}, (prev, next) => (
|
||||||
prev.session === next.session
|
prev.session === next.session
|
||||||
&& prev.host === next.host
|
&& prev.host === next.host
|
||||||
&& prev.isSelected === next.isSelected
|
&& prev.isSelected === next.isSelected
|
||||||
|
&& prev.isRenaming === next.isRenaming
|
||||||
|
&& prev.renameValue === next.renameValue
|
||||||
&& prev.isDragging === next.isDragging
|
&& prev.isDragging === next.isDragging
|
||||||
&& prev.dropPosition === next.dropPosition
|
&& prev.dropPosition === next.dropPosition
|
||||||
&& prev.theme === next.theme
|
&& prev.theme === next.theme
|
||||||
&& prev.onSelect === next.onSelect
|
&& 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.onDragStart === next.onDragStart
|
||||||
&& prev.onDragOver === next.onDragOver
|
&& prev.onDragOver === next.onDragOver
|
||||||
&& prev.onDrop === next.onDrop
|
&& prev.onDrop === next.onDrop
|
||||||
&& prev.onDragEnd === next.onDragEnd
|
&& prev.onDragEnd === next.onDragEnd
|
||||||
|
&& prev.t === next.t
|
||||||
));
|
));
|
||||||
WorkspaceFocusSessionRow.displayName = 'WorkspaceFocusSessionRow';
|
WorkspaceFocusSessionRow.displayName = 'WorkspaceFocusSessionRow';
|
||||||
|
|
||||||
@@ -165,8 +234,13 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
|||||||
focusedSessionId,
|
focusedSessionId,
|
||||||
onReorderWorkspaceSessions,
|
onReorderWorkspaceSessions,
|
||||||
onRequestAddToWorkspace,
|
onRequestAddToWorkspace,
|
||||||
|
onCloseSession,
|
||||||
|
onCopySession,
|
||||||
|
onCopySessionToNewWindow,
|
||||||
|
onDetachSessionFromWorkspace,
|
||||||
onSetWorkspaceFocusedSession,
|
onSetWorkspaceFocusedSession,
|
||||||
onToggleWorkspaceViewMode,
|
onToggleWorkspaceViewMode,
|
||||||
|
onSubmitSessionRename,
|
||||||
resolvedPreviewTheme,
|
resolvedPreviewTheme,
|
||||||
sessionHostsMap,
|
sessionHostsMap,
|
||||||
sessions,
|
sessions,
|
||||||
@@ -182,6 +256,9 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
|||||||
STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH, 224, { min: 160, max: 480 },
|
STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH, 224, { min: 160, max: 480 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [sidebarRenameSessionId, setSidebarRenameSessionId] = useState<string | null>(null);
|
||||||
|
const [sidebarRenameValue, setSidebarRenameValue] = useState('');
|
||||||
|
|
||||||
const theme = useMemo<FocusSidebarTheme>(() => {
|
const theme = useMemo<FocusSidebarTheme>(() => {
|
||||||
const termBg = resolvedPreviewTheme.colors.background;
|
const termBg = resolvedPreviewTheme.colors.background;
|
||||||
const termFg = resolvedPreviewTheme.colors.foreground;
|
const termFg = resolvedPreviewTheme.colors.foreground;
|
||||||
@@ -208,7 +285,8 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
|||||||
const term = focusSidebarSearch.trim().toLowerCase();
|
const term = focusSidebarSearch.trim().toLowerCase();
|
||||||
if (!term) return workspaceSessions;
|
if (!term) return workspaceSessions;
|
||||||
return workspaceSessions.filter((session) => (
|
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.hostname?.toLowerCase().includes(term)
|
||||||
|| session.username?.toLowerCase().includes(term)
|
|| session.username?.toLowerCase().includes(term)
|
||||||
));
|
));
|
||||||
@@ -349,6 +427,25 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
|||||||
onSetWorkspaceFocusedSession?.(activeWorkspace.id, sessionId);
|
onSetWorkspaceFocusedSession?.(activeWorkspace.id, sessionId);
|
||||||
}, [activeWorkspace.id, onSetWorkspaceFocusedSession]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex-shrink-0 flex flex-col relative"
|
className="flex-shrink-0 flex flex-col relative"
|
||||||
@@ -426,6 +523,15 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
|||||||
session={session}
|
session={session}
|
||||||
host={sessionHostsMap.get(session.id)}
|
host={sessionHostsMap.get(session.id)}
|
||||||
isSelected={session.id === focusedSessionId}
|
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}
|
isDragging={focusSidebarDragSessionId === session.id}
|
||||||
dropPosition={
|
dropPosition={
|
||||||
focusSidebarDropIndicator?.sessionId === session.id
|
focusSidebarDropIndicator?.sessionId === session.id
|
||||||
@@ -438,6 +544,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
|||||||
onDragOver={handleFocusSidebarDragOver}
|
onDragOver={handleFocusSidebarDragOver}
|
||||||
onDrop={handleFocusSidebarDrop}
|
onDrop={handleFocusSidebarDrop}
|
||||||
onDragEnd={handleFocusSidebarDragEnd}
|
onDragEnd={handleFocusSidebarDragEnd}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -451,6 +558,11 @@ function terminalFocusSidebarPropsEqual(
|
|||||||
next: TerminalFocusSidebarProps,
|
next: TerminalFocusSidebarProps,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (prev.focusedSessionId !== next.focusedSessionId) return false;
|
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.resolvedPreviewTheme !== next.resolvedPreviewTheme) return false;
|
||||||
if (prev.sessionHostsMap !== next.sessionHostsMap) return false;
|
if (prev.sessionHostsMap !== next.sessionHostsMap) return false;
|
||||||
if (prev.sessions !== next.sessions) return false;
|
if (prev.sessions !== next.sessions) return false;
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ function TerminalLayerFocusSidebarSectionInner({ ctx }: { ctx: FocusSidebarConte
|
|||||||
focusedSessionId={ctx.focusedSessionId}
|
focusedSessionId={ctx.focusedSessionId}
|
||||||
onReorderWorkspaceSessions={ctx.onReorderWorkspaceSessions}
|
onReorderWorkspaceSessions={ctx.onReorderWorkspaceSessions}
|
||||||
onRequestAddToWorkspace={ctx.onRequestAddToWorkspace}
|
onRequestAddToWorkspace={ctx.onRequestAddToWorkspace}
|
||||||
|
onCloseSession={ctx.handleCloseSession}
|
||||||
|
onCopySession={ctx.onCopySession}
|
||||||
|
onCopySessionToNewWindow={ctx.onCopySessionToNewWindow}
|
||||||
|
onDetachSessionFromWorkspace={ctx.onRemoveSessionFromWorkspace}
|
||||||
onSetWorkspaceFocusedSession={ctx.onSetWorkspaceFocusedSession}
|
onSetWorkspaceFocusedSession={ctx.onSetWorkspaceFocusedSession}
|
||||||
onToggleWorkspaceViewMode={ctx.onToggleWorkspaceViewMode}
|
onToggleWorkspaceViewMode={ctx.onToggleWorkspaceViewMode}
|
||||||
|
onSubmitSessionRename={ctx.onSubmitSessionRename}
|
||||||
resolvedPreviewTheme={ctx.resolvedPreviewTheme}
|
resolvedPreviewTheme={ctx.resolvedPreviewTheme}
|
||||||
sessionHostsMap={ctx.sessionHostsMap}
|
sessionHostsMap={ctx.sessionHostsMap}
|
||||||
sessions={ctx.sessions}
|
sessions={ctx.sessions}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { activeTabStore } from '../../application/state/activeTabStore';
|
|||||||
import { useTerminalLayoutSuppressActive } from '../../application/state/terminalLayoutSuppressStore';
|
import { useTerminalLayoutSuppressActive } from '../../application/state/terminalLayoutSuppressStore';
|
||||||
import type { TerminalSessionExitEvent } from '../../application/state/resolveTerminalSessionExitIntent';
|
import type { TerminalSessionExitEvent } from '../../application/state/resolveTerminalSessionExitIntent';
|
||||||
import { createTerminalSelectionAttachment } from '../../application/state/terminalSelectionAttachment';
|
import { createTerminalSelectionAttachment } from '../../application/state/terminalSelectionAttachment';
|
||||||
|
import { getTopTabInsertionTarget, isPointInsideRect, WORKSPACE_SESSION_DRAG_TYPE } from '../../application/state/terminalDragData';
|
||||||
import { useAIState } from '../../application/state/useAIState';
|
import { useAIState } from '../../application/state/useAIState';
|
||||||
import { useStoredBoolean } from '../../application/state/useStoredBoolean';
|
import { useStoredBoolean } from '../../application/state/useStoredBoolean';
|
||||||
import { SplitDirection } from '../../domain/workspace';
|
import { collectSessionIds, SplitDirection } from '../../domain/workspace';
|
||||||
import { KeyBinding, TerminalSettings } from '../../domain/models';
|
import { KeyBinding, TerminalSettings } from '../../domain/models';
|
||||||
import { STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION } from '../../infrastructure/config/storageKeys';
|
import { STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION } from '../../infrastructure/config/storageKeys';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
@@ -501,6 +502,13 @@ export interface TerminalLayerProps {
|
|||||||
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
|
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
|
||||||
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
|
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
|
||||||
onReorderWorkspaceSessions?: (workspaceId: string, draggedSessionId: string, targetSessionId: string, position: 'before' | 'after') => 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;
|
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
|
||||||
onConnectToHost: (host: Host) => void;
|
onConnectToHost: (host: Host) => void;
|
||||||
onCreateLocalTerminal?: () => void;
|
onCreateLocalTerminal?: () => void;
|
||||||
@@ -530,6 +538,9 @@ export interface TerminalLayerProps {
|
|||||||
showHostTreeSidebar?: boolean;
|
showHostTreeSidebar?: boolean;
|
||||||
toggleScriptsSidePanelRef?: React.MutableRefObject<(() => void) | null>;
|
toggleScriptsSidePanelRef?: React.MutableRefObject<(() => void) | null>;
|
||||||
toggleSidePanelRef?: React.MutableRefObject<(() => void) | null>;
|
toggleSidePanelRef?: React.MutableRefObject<(() => void) | null>;
|
||||||
|
// Session rename
|
||||||
|
onStartSessionRename?: (sessionId: string) => void;
|
||||||
|
onSubmitSessionRename?: (sessionId?: string, name?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TerminalPaneProps {
|
interface TerminalPaneProps {
|
||||||
@@ -597,6 +608,14 @@ interface TerminalPaneProps {
|
|||||||
) => void;
|
) => void;
|
||||||
onAddSelectionToAI?: (sessionId: string, selection: string) => void;
|
onAddSelectionToAI?: (sessionId: string, selection: string) => void;
|
||||||
showSelectionAIAction: boolean;
|
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 => (
|
const getPaneThemePreviewId = (props: TerminalPaneProps): string | null => (
|
||||||
@@ -684,7 +703,12 @@ const terminalPanePropsAreEqual = (
|
|||||||
prev.onToggleWorkspaceComposeBar === next.onToggleWorkspaceComposeBar &&
|
prev.onToggleWorkspaceComposeBar === next.onToggleWorkspaceComposeBar &&
|
||||||
prev.onSnippetExecutorChange === next.onSnippetExecutorChange &&
|
prev.onSnippetExecutorChange === next.onSnippetExecutorChange &&
|
||||||
prev.onAddSelectionToAI === next.onAddSelectionToAI &&
|
prev.onAddSelectionToAI === next.onAddSelectionToAI &&
|
||||||
prev.showSelectionAIAction === next.showSelectionAIAction
|
prev.showSelectionAIAction === next.showSelectionAIAction &&
|
||||||
|
prev.onStartSessionRename === next.onStartSessionRename &&
|
||||||
|
prev.onRemoveSessionFromWorkspace === next.onRemoveSessionFromWorkspace &&
|
||||||
|
prev.onReorderTabs === next.onReorderTabs &&
|
||||||
|
prev.onStartSessionDrag === next.onStartSessionDrag &&
|
||||||
|
prev.onEndSessionDrag === next.onEndSessionDrag
|
||||||
);
|
);
|
||||||
|
|
||||||
const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||||
@@ -743,6 +767,11 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
|||||||
onSnippetExecutorChange,
|
onSnippetExecutorChange,
|
||||||
onAddSelectionToAI,
|
onAddSelectionToAI,
|
||||||
showSelectionAIAction,
|
showSelectionAIAction,
|
||||||
|
onStartSessionRename,
|
||||||
|
onRemoveSessionFromWorkspace,
|
||||||
|
onReorderTabs,
|
||||||
|
onStartSessionDrag,
|
||||||
|
onEndSessionDrag,
|
||||||
}) => {
|
}) => {
|
||||||
const layoutSuppressActive = useTerminalLayoutSuppressActive();
|
const layoutSuppressActive = useTerminalLayoutSuppressActive();
|
||||||
const deferPaneLayoutUpdate = isResizing || layoutSuppressActive;
|
const deferPaneLayoutUpdate = isResizing || layoutSuppressActive;
|
||||||
@@ -855,6 +884,192 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
|||||||
}
|
}
|
||||||
onOpenSystem?.();
|
onOpenSystem?.();
|
||||||
}, [activeWorkspaceId, isFocusMode, onOpenSystem, onSetWorkspaceFocusedSession, session.id]);
|
}, [activeWorkspaceId, isFocusMode, onOpenSystem, onSetWorkspaceFocusedSession, session.id]);
|
||||||
|
const handleRename = useCallback(() => {
|
||||||
|
onStartSessionRename?.(session.id);
|
||||||
|
}, [onStartSessionRename, session.id]);
|
||||||
|
const handleDetach = useCallback(() => {
|
||||||
|
onRemoveSessionFromWorkspace?.(session.id);
|
||||||
|
}, [onRemoveSessionFromWorkspace, session.id]);
|
||||||
|
const handleDetachDragStart = useCallback((e: React.DragEvent) => {
|
||||||
|
if (!inActiveWorkspace) return;
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData(WORKSPACE_SESSION_DRAG_TYPE, session.id);
|
||||||
|
e.dataTransfer.setData('session-id', session.id);
|
||||||
|
e.dataTransfer.setData('text/plain', session.id);
|
||||||
|
onStartSessionDrag?.(session.id);
|
||||||
|
}, [inActiveWorkspace, onStartSessionDrag, session.id]);
|
||||||
|
const handleDetachDragEnd = useCallback(() => {
|
||||||
|
onEndSessionDrag?.();
|
||||||
|
}, [onEndSessionDrag]);
|
||||||
|
const handleDetachPointerDown = useCallback((e: React.PointerEvent<HTMLElement>) => {
|
||||||
|
if (!inActiveWorkspace || e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const startPoint = { clientX: e.clientX, clientY: e.clientY };
|
||||||
|
const dragLabel = session.customName || session.hostLabel;
|
||||||
|
let dragStarted = false;
|
||||||
|
let ghostEl: HTMLDivElement | null = null;
|
||||||
|
let insertEl: HTMLDivElement | null = null;
|
||||||
|
|
||||||
|
const ensureDragElements = () => {
|
||||||
|
if (!ghostEl) {
|
||||||
|
ghostEl = document.createElement('div');
|
||||||
|
ghostEl.textContent = dragLabel;
|
||||||
|
ghostEl.style.position = 'fixed';
|
||||||
|
ghostEl.style.left = '0';
|
||||||
|
ghostEl.style.top = '0';
|
||||||
|
ghostEl.style.zIndex = '2147483647';
|
||||||
|
ghostEl.style.pointerEvents = 'none';
|
||||||
|
ghostEl.style.maxWidth = '220px';
|
||||||
|
ghostEl.style.padding = '5px 10px';
|
||||||
|
ghostEl.style.borderRadius = '7px';
|
||||||
|
ghostEl.style.border = '1px solid color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 60%, transparent)';
|
||||||
|
ghostEl.style.background = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 90%, transparent)';
|
||||||
|
ghostEl.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||||
|
ghostEl.style.boxShadow = '0 12px 28px rgba(0, 0, 0, 0.28)';
|
||||||
|
ghostEl.style.fontSize = '12px';
|
||||||
|
ghostEl.style.fontWeight = '600';
|
||||||
|
ghostEl.style.whiteSpace = 'nowrap';
|
||||||
|
ghostEl.style.overflow = 'hidden';
|
||||||
|
ghostEl.style.textOverflow = 'ellipsis';
|
||||||
|
document.body.appendChild(ghostEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!insertEl) {
|
||||||
|
insertEl = document.createElement('div');
|
||||||
|
insertEl.style.position = 'fixed';
|
||||||
|
insertEl.style.zIndex = '2147483646';
|
||||||
|
insertEl.style.pointerEvents = 'none';
|
||||||
|
insertEl.style.width = '2px';
|
||||||
|
insertEl.style.borderRadius = '999px';
|
||||||
|
insertEl.style.background = 'var(--top-tabs-accent, hsl(var(--accent)))';
|
||||||
|
insertEl.style.boxShadow = '0 0 10px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 70%, transparent)';
|
||||||
|
insertEl.style.display = 'none';
|
||||||
|
document.body.appendChild(insertEl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDragElements = () => {
|
||||||
|
ghostEl?.remove();
|
||||||
|
insertEl?.remove();
|
||||||
|
ghostEl = null;
|
||||||
|
insertEl = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDragElements = (event: PointerEvent) => {
|
||||||
|
ensureDragElements();
|
||||||
|
if (ghostEl) {
|
||||||
|
ghostEl.style.transform = `translate(${event.clientX + 12}px, ${event.clientY + 10}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const topTabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||||
|
const insertionTarget = getTopTabInsertionTarget(event, topTabsRoot);
|
||||||
|
if (!topTabsRoot || !insertionTarget || !insertEl) {
|
||||||
|
if (insertEl) insertEl.style.display = 'none';
|
||||||
|
return insertionTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTab = Array.from(topTabsRoot.querySelectorAll<HTMLElement>('[data-tab-id]'))
|
||||||
|
.find((tab) => tab.dataset.tabId === insertionTarget.tabId);
|
||||||
|
if (!targetTab) {
|
||||||
|
insertEl.style.display = 'none';
|
||||||
|
return insertionTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRect = targetTab.getBoundingClientRect();
|
||||||
|
const rootRect = topTabsRoot.getBoundingClientRect();
|
||||||
|
const lineX = insertionTarget.position === 'before' ? targetRect.left : targetRect.right;
|
||||||
|
insertEl.style.display = 'block';
|
||||||
|
insertEl.style.left = `${lineX - 1}px`;
|
||||||
|
insertEl.style.top = `${Math.max(rootRect.top + 5, targetRect.top + 3)}px`;
|
||||||
|
insertEl.style.height = `${Math.max(18, Math.min(rootRect.bottom - rootRect.top - 8, targetRect.height - 4))}px`;
|
||||||
|
return insertionTarget;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveStableInsertionTarget = (insertionTarget: ReturnType<typeof getTopTabInsertionTarget>) => {
|
||||||
|
if (!insertionTarget || insertionTarget.tabId !== session.workspaceId) return insertionTarget;
|
||||||
|
const sourceWorkspace = session.workspaceId ? workspaceById.get(session.workspaceId) : undefined;
|
||||||
|
if (!sourceWorkspace) return insertionTarget;
|
||||||
|
const remainingSessionIds = collectSessionIds(sourceWorkspace.root)
|
||||||
|
.filter((candidateId) => candidateId !== session.id);
|
||||||
|
if (remainingSessionIds.length !== 1) return insertionTarget;
|
||||||
|
return {
|
||||||
|
tabId: remainingSessionIds[0],
|
||||||
|
position: insertionTarget.position,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDragIfNeeded = (event: PointerEvent) => {
|
||||||
|
if (dragStarted) return;
|
||||||
|
const dx = event.clientX - startPoint.clientX;
|
||||||
|
const dy = event.clientY - startPoint.clientY;
|
||||||
|
if (Math.hypot(dx, dy) < 4) return;
|
||||||
|
dragStarted = true;
|
||||||
|
onStartSessionDrag?.(session.id);
|
||||||
|
updateDragElements(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
document.removeEventListener('pointermove', handlePointerMove, true);
|
||||||
|
document.removeEventListener('pointerup', handlePointerUp, true);
|
||||||
|
document.removeEventListener('pointercancel', handlePointerCancel, true);
|
||||||
|
removeDragElements();
|
||||||
|
if (dragStarted) onEndSessionDrag?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
|
startDragIfNeeded(event);
|
||||||
|
if (dragStarted) updateDragElements(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerCancel = () => {
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (event: PointerEvent) => {
|
||||||
|
startDragIfNeeded(event);
|
||||||
|
const topTabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||||
|
const insertionTarget = dragStarted ? updateDragElements(event) : null;
|
||||||
|
const shouldDetach = dragStarted && !!topTabsRoot && isPointInsideRect(event, topTabsRoot.getBoundingClientRect());
|
||||||
|
cleanup();
|
||||||
|
if (shouldDetach) {
|
||||||
|
const stableInsertionTarget = resolveStableInsertionTarget(insertionTarget);
|
||||||
|
if (onRemoveSessionFromWorkspace) {
|
||||||
|
onRemoveSessionFromWorkspace(
|
||||||
|
session.id,
|
||||||
|
stableInsertionTarget
|
||||||
|
? {
|
||||||
|
tabId: stableInsertionTarget.tabId,
|
||||||
|
position: stableInsertionTarget.position,
|
||||||
|
additionalTabIds: [session.id, stableInsertionTarget.tabId],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
} else if (stableInsertionTarget) {
|
||||||
|
onReorderTabs?.(session.id, stableInsertionTarget.tabId, stableInsertionTarget.position, [
|
||||||
|
session.id,
|
||||||
|
stableInsertionTarget.tabId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('pointermove', handlePointerMove, true);
|
||||||
|
document.addEventListener('pointerup', handlePointerUp, true);
|
||||||
|
document.addEventListener('pointercancel', handlePointerCancel, true);
|
||||||
|
}, [
|
||||||
|
inActiveWorkspace,
|
||||||
|
onEndSessionDrag,
|
||||||
|
onRemoveSessionFromWorkspace,
|
||||||
|
onReorderTabs,
|
||||||
|
onStartSessionDrag,
|
||||||
|
session.customName,
|
||||||
|
session.hostLabel,
|
||||||
|
session.id,
|
||||||
|
session.workspaceId,
|
||||||
|
workspaceById,
|
||||||
|
]);
|
||||||
const handleTerminalFontSizeChange = useCallback((nextFontSize: number) => {
|
const handleTerminalFontSizeChange = useCallback((nextFontSize: number) => {
|
||||||
onTerminalFontSizeChange?.(session.id, nextFontSize);
|
onTerminalFontSizeChange?.(session.id, nextFontSize);
|
||||||
}, [onTerminalFontSizeChange, session.id]);
|
}, [onTerminalFontSizeChange, session.id]);
|
||||||
@@ -931,8 +1146,16 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
|||||||
sessionLog={sessionLog}
|
sessionLog={sessionLog}
|
||||||
sshDebugLogEnabled={sshDebugLogEnabled}
|
sshDebugLogEnabled={sshDebugLogEnabled}
|
||||||
sudoAutofillPassword={sudoAutofillPassword}
|
sudoAutofillPassword={sudoAutofillPassword}
|
||||||
|
sessionDisplayName={session.customName || session.hostLabel}
|
||||||
showSelectionAIAction={showSelectionAIAction}
|
showSelectionAIAction={showSelectionAIAction}
|
||||||
onAddSelectionToAI={onAddSelectionToAI}
|
onAddSelectionToAI={onAddSelectionToAI}
|
||||||
|
onRename={handleRename}
|
||||||
|
onDetach={inActiveWorkspace ? handleDetach : undefined}
|
||||||
|
onStartSessionDrag={inActiveWorkspace ? onStartSessionDrag : undefined}
|
||||||
|
onEndSessionDrag={inActiveWorkspace ? onEndSessionDrag : undefined}
|
||||||
|
onDetachPointerDown={inActiveWorkspace ? handleDetachPointerDown : undefined}
|
||||||
|
onDetachDragStart={inActiveWorkspace ? handleDetachDragStart : undefined}
|
||||||
|
onDetachDragEnd={inActiveWorkspace ? handleDetachDragEnd : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -998,6 +1221,11 @@ interface TerminalPanesHostProps {
|
|||||||
executor: SnippetExecutor | null,
|
executor: SnippetExecutor | null,
|
||||||
) => void;
|
) => void;
|
||||||
onAddSelectionToAI?: (sessionId: string, selection: string) => 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 = (
|
const terminalPanesHostPropsAreEqual = (
|
||||||
@@ -1057,6 +1285,11 @@ const terminalPanesHostPropsAreEqual = (
|
|||||||
if (prev.onToggleWorkspaceComposeBar !== next.onToggleWorkspaceComposeBar) return false;
|
if (prev.onToggleWorkspaceComposeBar !== next.onToggleWorkspaceComposeBar) return false;
|
||||||
if (prev.onSnippetExecutorChange !== next.onSnippetExecutorChange) return false;
|
if (prev.onSnippetExecutorChange !== next.onSnippetExecutorChange) return false;
|
||||||
if (prev.onAddSelectionToAI !== next.onAddSelectionToAI) 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;
|
if (prev.workspaceRectsById === next.workspaceRectsById) return true;
|
||||||
|
|
||||||
|
|||||||
@@ -392,8 +392,16 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
|||||||
onCreateLocalTerminal: s.onCreateLocalTerminal,
|
onCreateLocalTerminal: s.onCreateLocalTerminal,
|
||||||
onHotkeyAction: s.onHotkeyAction,
|
onHotkeyAction: s.onHotkeyAction,
|
||||||
onReorderWorkspaceSessions: s.onReorderWorkspaceSessions,
|
onReorderWorkspaceSessions: s.onReorderWorkspaceSessions,
|
||||||
|
onReorderTabs: s.onReorderTabs,
|
||||||
|
onCopySession: s.onCopySession,
|
||||||
|
onCopySessionToNewWindow: s.onCopySessionToNewWindow,
|
||||||
onRequestAddToWorkspace: s.onRequestAddToWorkspace,
|
onRequestAddToWorkspace: s.onRequestAddToWorkspace,
|
||||||
onSetWorkspaceFocusedSession: s.onSetWorkspaceFocusedSession,
|
onSetWorkspaceFocusedSession: s.onSetWorkspaceFocusedSession,
|
||||||
|
onStartSessionRename: s.onStartSessionRename,
|
||||||
|
onSubmitSessionRename: s.onSubmitSessionRename,
|
||||||
|
onRemoveSessionFromWorkspace: s.onRemoveSessionFromWorkspace,
|
||||||
|
onStartSessionDrag: s.onStartSessionDrag,
|
||||||
|
onEndSessionDrag: s.onEndSessionDrag,
|
||||||
onSplitSession: s.onSplitSession,
|
onSplitSession: s.onSplitSession,
|
||||||
onToggleWorkspaceViewMode: s.onToggleWorkspaceViewMode,
|
onToggleWorkspaceViewMode: s.onToggleWorkspaceViewMode,
|
||||||
Palette: s.Palette,
|
Palette: s.Palette,
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
|||||||
TerminalComposeBar,
|
TerminalComposeBar,
|
||||||
Array,
|
Array,
|
||||||
cn,
|
cn,
|
||||||
|
onStartSessionRename,
|
||||||
|
onRemoveSessionFromWorkspace,
|
||||||
|
onReorderTabs,
|
||||||
|
onStartSessionDrag,
|
||||||
|
onEndSessionDrag,
|
||||||
} = ctx;
|
} = ctx;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -180,6 +185,11 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
|||||||
onToggleWorkspaceComposeBar={handleToggleWorkspaceComposeBar}
|
onToggleWorkspaceComposeBar={handleToggleWorkspaceComposeBar}
|
||||||
onSnippetExecutorChange={handleSnippetExecutorChange}
|
onSnippetExecutorChange={handleSnippetExecutorChange}
|
||||||
onAddSelectionToAI={handleAddSelectionToAI}
|
onAddSelectionToAI={handleAddSelectionToAI}
|
||||||
|
onStartSessionRename={onStartSessionRename}
|
||||||
|
onRemoveSessionFromWorkspace={onRemoveSessionFromWorkspace}
|
||||||
|
onReorderTabs={onReorderTabs}
|
||||||
|
onStartSessionDrag={onStartSessionDrag}
|
||||||
|
onEndSessionDrag={onEndSessionDrag}
|
||||||
/>
|
/>
|
||||||
{!isFocusMode && activeResizers.map((handle: any) => {
|
{!isFocusMode && activeResizers.map((handle: any) => {
|
||||||
const isVertical = handle.direction === 'vertical';
|
const isVertical = handle.direction === 'vertical';
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ export type TerminalLayerStableSnapshot = {
|
|||||||
onConnectToHost: TerminalLayerProps['onConnectToHost'];
|
onConnectToHost: TerminalLayerProps['onConnectToHost'];
|
||||||
onCreateLocalTerminal: TerminalLayerProps['onCreateLocalTerminal'];
|
onCreateLocalTerminal: TerminalLayerProps['onCreateLocalTerminal'];
|
||||||
onReorderWorkspaceSessions: TerminalLayerProps['onReorderWorkspaceSessions'];
|
onReorderWorkspaceSessions: TerminalLayerProps['onReorderWorkspaceSessions'];
|
||||||
|
onReorderTabs: TerminalLayerProps['onReorderTabs'];
|
||||||
|
onCopySession: TerminalLayerProps['onCopySession'];
|
||||||
|
onCopySessionToNewWindow: TerminalLayerProps['onCopySessionToNewWindow'];
|
||||||
onRequestAddToWorkspace: TerminalLayerProps['onRequestAddToWorkspace'];
|
onRequestAddToWorkspace: TerminalLayerProps['onRequestAddToWorkspace'];
|
||||||
onSetWorkspaceFocusedSession: TerminalLayerProps['onSetWorkspaceFocusedSession'];
|
onSetWorkspaceFocusedSession: TerminalLayerProps['onSetWorkspaceFocusedSession'];
|
||||||
onToggleWorkspaceViewMode: TerminalLayerProps['onToggleWorkspaceViewMode'];
|
onToggleWorkspaceViewMode: TerminalLayerProps['onToggleWorkspaceViewMode'];
|
||||||
|
|||||||
@@ -284,6 +284,11 @@ const WORKSPACE_CTX_KEYS = [
|
|||||||
'setResizing',
|
'setResizing',
|
||||||
'Array',
|
'Array',
|
||||||
'cn',
|
'cn',
|
||||||
|
'onStartSessionRename',
|
||||||
|
'onRemoveSessionFromWorkspace',
|
||||||
|
'onReorderTabs',
|
||||||
|
'onStartSessionDrag',
|
||||||
|
'onEndSessionDrag',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function terminalLayerSidePanelCtxEqual(prev: Ctx, next: Ctx): boolean {
|
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, 't')
|
||||||
&& eq(prev, next, 'onReorderWorkspaceSessions')
|
&& eq(prev, next, 'onReorderWorkspaceSessions')
|
||||||
&& eq(prev, next, 'onRequestAddToWorkspace')
|
&& 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, 'onSetWorkspaceFocusedSession')
|
||||||
&& eq(prev, next, 'onToggleWorkspaceViewMode');
|
&& eq(prev, next, 'onToggleWorkspaceViewMode')
|
||||||
|
&& eq(prev, next, 'onSubmitSessionRename');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const terminalLayerAreEqual = (
|
|||||||
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
|
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
|
||||||
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
||||||
prev.onReorderWorkspaceSessions === next.onReorderWorkspaceSessions &&
|
prev.onReorderWorkspaceSessions === next.onReorderWorkspaceSessions &&
|
||||||
|
prev.onReorderTabs === next.onReorderTabs &&
|
||||||
prev.onSplitSession === next.onSplitSession &&
|
prev.onSplitSession === next.onSplitSession &&
|
||||||
prev.onConnectToHost === next.onConnectToHost &&
|
prev.onConnectToHost === next.onConnectToHost &&
|
||||||
prev.onCreateLocalTerminal === next.onCreateLocalTerminal &&
|
prev.onCreateLocalTerminal === next.onCreateLocalTerminal &&
|
||||||
|
|||||||
55
components/top-tabs/SessionTabContextMenuContent.tsx
Normal file
55
components/top-tabs/SessionTabContextMenuContent.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import type { useI18n } from '../../application/i18n/I18nProvider';
|
||||||
|
import { ContextMenuContent, ContextMenuItem } from '../ui/context-menu';
|
||||||
|
|
||||||
|
type TranslateFn = ReturnType<typeof useI18n>['t'];
|
||||||
|
|
||||||
|
interface SessionTabContextMenuContentProps {
|
||||||
|
sessionId: string;
|
||||||
|
onCloseSession: (sessionId: string) => void;
|
||||||
|
onCopySession?: (sessionId: string) => void;
|
||||||
|
onCopySessionToNewWindow?: (sessionId: string) => void;
|
||||||
|
onDetachSession?: (sessionId: string) => void;
|
||||||
|
onRenameSession: (sessionId: string) => void;
|
||||||
|
renderBulkCloseItems?: (anchorId: string) => React.ReactNode;
|
||||||
|
t: TranslateFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionTabContextMenuContent({
|
||||||
|
sessionId,
|
||||||
|
onCloseSession,
|
||||||
|
onCopySession,
|
||||||
|
onCopySessionToNewWindow,
|
||||||
|
onDetachSession,
|
||||||
|
onRenameSession,
|
||||||
|
renderBulkCloseItems,
|
||||||
|
t,
|
||||||
|
}: SessionTabContextMenuContentProps) {
|
||||||
|
return (
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem onClick={() => onRenameSession(sessionId)}>
|
||||||
|
{t('common.rename')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
{onCopySession && (
|
||||||
|
<ContextMenuItem onClick={() => onCopySession(sessionId)}>
|
||||||
|
{t('tabs.copyTab')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
{onCopySessionToNewWindow && (
|
||||||
|
<ContextMenuItem onClick={() => onCopySessionToNewWindow(sessionId)}>
|
||||||
|
{t('tabs.copyTabToNewWindow')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
{onDetachSession && (
|
||||||
|
<ContextMenuItem onClick={() => onDetachSession(sessionId)}>
|
||||||
|
{t('terminal.menu.detach')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(sessionId)}>
|
||||||
|
{t('common.close')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
{renderBulkCloseItems?.(sessionId)}
|
||||||
|
</ContextMenuContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,8 +11,9 @@ import { Host, TerminalSession, Workspace } from '../../types';
|
|||||||
import { DISTRO_LOGOS, DISTRO_COLORS } from '../DistroAvatar';
|
import { DISTRO_LOGOS, DISTRO_COLORS } from '../DistroAvatar';
|
||||||
import { getShellIconPath, isMonochromeShellIcon } from '../../lib/useDiscoveredShells';
|
import { getShellIconPath, isMonochromeShellIcon } from '../../lib/useDiscoveredShells';
|
||||||
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../../lib/tabInteractions';
|
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 { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||||
|
import { SessionTabContextMenuContent } from './SessionTabContextMenuContent';
|
||||||
|
|
||||||
// File extensions that render the code-file icon instead of the plain text icon.
|
// 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;
|
const CODE_EXTENSIONS_RE = /\.(js|jsx|ts|tsx|py|rb|go|rs|c|cpp|cs|java|php|sh|bash|zsh|fish|lua|r|scala|swift|kt|html|css|scss|less|json|yaml|yml|toml|xml|sql|graphql|gql|md|mdx|conf|ini|env|tf|hcl|dockerfile)$/i;
|
||||||
@@ -555,7 +556,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
|||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<SessionTabIcon host={host} isActive={isActive} protocol={session.protocol} shellIcon={session.localShellIcon} />
|
<SessionTabIcon host={host} isActive={isActive} protocol={session.protocol} shellIcon={session.localShellIcon} />
|
||||||
<span className="truncate">{session.hostLabel}</span>
|
<span className="truncate">{session.customName || session.hostLabel}</span>
|
||||||
<div className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</div>
|
<div className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -567,21 +568,15 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<SessionTabContextMenuContent
|
||||||
<ContextMenuItem onClick={() => onRenameSession(session.id)}>
|
sessionId={session.id}
|
||||||
{t('common.rename')}
|
onCloseSession={onCloseSession}
|
||||||
</ContextMenuItem>
|
onCopySession={onCopySession}
|
||||||
<ContextMenuItem onClick={() => onCopySession(session.id)}>
|
onCopySessionToNewWindow={onCopySessionToNewWindow}
|
||||||
{t('tabs.copyTab')}
|
onRenameSession={onRenameSession}
|
||||||
</ContextMenuItem>
|
renderBulkCloseItems={renderBulkCloseItems}
|
||||||
<ContextMenuItem onClick={() => onCopySessionToNewWindow(session.id)}>
|
t={t}
|
||||||
{t('tabs.copyTabToNewWindow')}
|
/>
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(session.id)}>
|
|
||||||
{t('common.close')}
|
|
||||||
</ContextMenuItem>
|
|
||||||
{renderBulkCloseItems(session.id)}
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -603,6 +598,8 @@ interface WorkspaceTopTabProps {
|
|||||||
onTabDrop: (e: React.DragEvent, targetTabId: string) => void;
|
onTabDrop: (e: React.DragEvent, targetTabId: string) => void;
|
||||||
onRenameWorkspace: (workspaceId: string) => void;
|
onRenameWorkspace: (workspaceId: string) => void;
|
||||||
onCloseWorkspace: (workspaceId: string) => void;
|
onCloseWorkspace: (workspaceId: string) => void;
|
||||||
|
onDetachSessionFromWorkspace?: (workspaceId: string, sessionId: string) => void;
|
||||||
|
workspaceSessionLabels?: Record<string, string>;
|
||||||
renderBulkCloseItems: RenderBulkCloseItems;
|
renderBulkCloseItems: RenderBulkCloseItems;
|
||||||
t: TranslateFn;
|
t: TranslateFn;
|
||||||
tabAnimationClass?: string;
|
tabAnimationClass?: string;
|
||||||
@@ -624,6 +621,8 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
|||||||
onTabDrop,
|
onTabDrop,
|
||||||
onRenameWorkspace,
|
onRenameWorkspace,
|
||||||
onCloseWorkspace,
|
onCloseWorkspace,
|
||||||
|
onDetachSessionFromWorkspace,
|
||||||
|
workspaceSessionLabels,
|
||||||
renderBulkCloseItems,
|
renderBulkCloseItems,
|
||||||
t,
|
t,
|
||||||
tabAnimationClass,
|
tabAnimationClass,
|
||||||
@@ -715,6 +714,17 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
|||||||
<ContextMenuItem onClick={() => onRenameWorkspace(workspace.id)}>
|
<ContextMenuItem onClick={() => onRenameWorkspace(workspace.id)}>
|
||||||
{t('common.rename')}
|
{t('common.rename')}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
{onDetachSessionFromWorkspace && workspaceSessionLabels && Object.entries(workspaceSessionLabels).map(([sessionId, label]) => (
|
||||||
|
<ContextMenuItem
|
||||||
|
key={sessionId}
|
||||||
|
onClick={() => onDetachSessionFromWorkspace(workspace.id, sessionId)}
|
||||||
|
>
|
||||||
|
{t('terminal.menu.detachSession', { name: label })}
|
||||||
|
</ContextMenuItem>
|
||||||
|
))}
|
||||||
|
{onDetachSessionFromWorkspace && workspaceSessionLabels && Object.keys(workspaceSessionLabels).length > 0 && (
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
)}
|
||||||
<ContextMenuItem className="text-destructive" onClick={() => onCloseWorkspace(workspace.id)}>
|
<ContextMenuItem className="text-destructive" onClick={() => onCloseWorkspace(workspace.id)}>
|
||||||
{t('common.close')}
|
{t('common.close')}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
|||||||
@@ -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: '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: '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-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' },
|
{ id: 'new-tab', action: 'newTab', label: 'New Local Tab', mac: '⌘ + T', pc: 'Ctrl + T', category: 'tabs' },
|
||||||
|
|
||||||
// Terminal Operations
|
// 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: '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-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: '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
|
// App Features
|
||||||
{ id: 'open-hosts', action: 'openHosts', label: 'Open Hosts Page', mac: 'Disabled', pc: 'Disabled', category: 'app' },
|
{ id: 'open-hosts', action: 'openHosts', label: 'Open Hosts Page', mac: 'Disabled', pc: 'Disabled', category: 'app' },
|
||||||
|
|||||||
@@ -391,4 +391,6 @@ export interface TerminalSession {
|
|||||||
// Per-pane font size override (workspace splits only; not persisted to vault hosts).
|
// Per-pane font size override (workspace splits only; not persisted to vault hosts).
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
fontSizeOverride?: boolean;
|
fontSizeOverride?: boolean;
|
||||||
|
/** User-assigned display name for this terminal session (overrides hostLabel in UI) */
|
||||||
|
customName?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
20
index.css
20
index.css
@@ -181,6 +181,26 @@
|
|||||||
transition: width 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
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 {
|
.host-tree-notes-scroll {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: hsl(var(--muted-foreground) / 0.28) transparent;
|
scrollbar-color: hsl(var(--muted-foreground) / 0.28) transparent;
|
||||||
|
|||||||
Reference in New Issue
Block a user