* 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>
599 lines
28 KiB
TypeScript
599 lines
28 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import React, { Suspense, lazy } from 'react';
|
|
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
|
import { activeTabStore, toEditorTabId } from '../state/activeTabStore';
|
|
import { editorTabStore } from '../state/editorTabStore';
|
|
import { releaseEditorTabSaveCoordinator, saveEditorTab } from '../state/editorTabSave';
|
|
import { TopTabs } from '../../components/TopTabs';
|
|
import { VaultView } from '../../components/VaultView';
|
|
import { QuickAddSnippetDialog } from '../../components/QuickAddSnippetDialog';
|
|
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
|
|
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
|
|
import { PassphraseModal } from '../../components/PassphraseModal';
|
|
import { TextEditorTabView } from '../../components/editor/TextEditorTabView';
|
|
import { UnsavedChangesProvider } from '../../components/editor/UnsavedChangesDialog';
|
|
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
|
|
import { Button } from '../../components/ui/button';
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog';
|
|
import { Input } from '../../components/ui/input';
|
|
import { Label } from '../../components/ui/label';
|
|
import { toast } from '../../components/ui/toast';
|
|
import { AppHostTreeLayer } from './AppHostTreeLayer';
|
|
|
|
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
|
|
const LazyQuickSwitcher = lazy(() =>
|
|
import('../../components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
|
|
);
|
|
const LazyCreateWorkspaceDialog = lazy(() =>
|
|
import('../../components/CreateWorkspaceDialog').then((m) => ({ default: m.CreateWorkspaceDialog })),
|
|
);
|
|
|
|
type AppViewContext = Record<string, any>;
|
|
|
|
export function AppView({ ctx }: { ctx: AppViewContext }) {
|
|
const {
|
|
accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
|
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell,
|
|
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, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
|
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
|
|
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, updateSessionFontSize, clearSessionFontSizeOverride,
|
|
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
|
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
|
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId,
|
|
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
|
|
updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
|
|
updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces,
|
|
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
|
|
} = ctx;
|
|
|
|
return (
|
|
<SnippetExecutionProvider>
|
|
<UnsavedChangesProvider>
|
|
{({ prompt }) => {
|
|
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
|
|
const closeEditorAndActivateNeighbor = (id: string) => {
|
|
const closingTabId = toEditorTabId(id);
|
|
const list = orderedTabsWithEditors;
|
|
const idx = list.indexOf(closingTabId);
|
|
releaseEditorTabSaveCoordinator(id);
|
|
editorTabStore.close(id);
|
|
if (activeTabStore.getActiveTabId() !== closingTabId) return;
|
|
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
|
|
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
|
|
};
|
|
|
|
// Real dirty-confirm close handler.
|
|
const handleRequestCloseEditorTab = async (id: string): Promise<boolean> => {
|
|
const tab = editorTabStore.getTab(id);
|
|
if (!tab) return false;
|
|
const dirty = tab.content !== tab.baselineContent;
|
|
if (!dirty) {
|
|
closeEditorAndActivateNeighbor(id);
|
|
return true;
|
|
}
|
|
const choice = await prompt(tab.fileName);
|
|
if (choice === 'cancel') return false;
|
|
if (choice === 'discard') {
|
|
closeEditorAndActivateNeighbor(id);
|
|
return true;
|
|
}
|
|
if (choice === 'save') {
|
|
const ok = await saveEditorTab(id);
|
|
if (!ok) {
|
|
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
|
|
toast.error(msg, 'SFTP');
|
|
return false;
|
|
}
|
|
const latest = editorTabStore.getTab(id);
|
|
if (!latest || latest.content !== latest.baselineContent) return false;
|
|
closeEditorAndActivateNeighbor(id);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
|
|
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
|
|
|
return (
|
|
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
|
|
<TopTabs
|
|
theme={resolvedTheme}
|
|
hosts={hosts}
|
|
sessions={sessions}
|
|
orphanSessions={orphanSessions}
|
|
workspaces={workspaces}
|
|
logViews={logViews}
|
|
orderedTabs={orderedTabsWithEditors}
|
|
draggingSessionId={draggingSessionId}
|
|
isMacClient={isMacClient}
|
|
onCloseSession={closeSession}
|
|
onRenameSession={startSessionRename}
|
|
onCopySession={copySessionWithCurrentShell}
|
|
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
|
onRenameWorkspace={startWorkspaceRename}
|
|
onCloseWorkspace={closeWorkspace}
|
|
onCloseLogView={closeLogView}
|
|
onCloseTabsBatch={closeTabsBatch}
|
|
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
|
onToggleTheme={handleToggleTheme}
|
|
onOpenSettings={handleOpenSettings}
|
|
windowOpacity={settings.windowOpacity}
|
|
setWindowOpacity={settings.setWindowOpacity}
|
|
onSyncNow={handleSyncNowManual}
|
|
onStartSessionDrag={setDraggingSessionId}
|
|
onEndSessionDrag={handleEndSessionDrag}
|
|
onReorderTabs={reorderWorkTabs}
|
|
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
|
showSftpTab={settings.showSftpTab}
|
|
showHostTreeSidebar={settings.showHostTreeSidebar}
|
|
editorTabs={editorTabs}
|
|
onRequestCloseEditorTab={handleRequestCloseEditorTab}
|
|
hostById={hostById}
|
|
/>
|
|
|
|
<div className="flex-1 relative min-h-0">
|
|
<AppHostTreeLayer
|
|
enabled={settings.showHostTreeSidebar}
|
|
hosts={hosts}
|
|
customGroups={customGroups}
|
|
groupConfigs={groupConfigs}
|
|
sessions={sessions}
|
|
workspaces={workspaces}
|
|
editorTabs={editorTabs}
|
|
logViews={logViews}
|
|
orderedTabs={orderedTabsWithEditors}
|
|
resolvedPreviewTheme={currentTerminalTheme}
|
|
onConnect={handleConnectToHost}
|
|
onCreateLocalTerminal={handleCreateLocalTerminal}
|
|
/>
|
|
|
|
<VaultViewContainer>
|
|
<VaultView
|
|
hosts={hosts}
|
|
keys={keys}
|
|
identities={identities}
|
|
proxyProfiles={proxyProfiles}
|
|
snippets={snippets}
|
|
snippetPackages={snippetPackages}
|
|
customGroups={customGroups}
|
|
knownHosts={effectiveKnownHosts}
|
|
shellHistory={shellHistory}
|
|
connectionLogs={connectionLogs}
|
|
managedSources={managedSources}
|
|
sessionCount={sessions.length}
|
|
hotkeyScheme={hotkeyScheme}
|
|
keyBindings={keyBindings}
|
|
terminalThemeId={terminalThemeId}
|
|
terminalFontSize={terminalFontSize}
|
|
onOpenSettings={handleOpenSettings}
|
|
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
|
onCreateLocalTerminal={handleCreateLocalTerminal}
|
|
onConnectSerial={handleConnectSerial}
|
|
onDeleteHost={handleDeleteHost}
|
|
onConnect={handleConnectToHost}
|
|
groupConfigs={groupConfigs}
|
|
onUpdateGroupConfigs={updateGroupConfigs}
|
|
onUpdateHosts={updateHosts}
|
|
onUpdateKeys={updateKeys}
|
|
onImportOrReuseKey={importOrReuseKey}
|
|
onUpdateIdentities={updateIdentities}
|
|
onUpdateProxyProfiles={updateProxyProfiles}
|
|
onUpdateSnippets={updateSnippets}
|
|
onUpdateSnippetPackages={updateSnippetPackages}
|
|
onUpdateCustomGroups={updateCustomGroups}
|
|
onUpdateKnownHosts={updateKnownHosts}
|
|
onUpdateManagedSources={updateManagedSources}
|
|
onClearAndRemoveManagedSource={clearAndRemoveSource}
|
|
onClearAndRemoveManagedSources={clearAndRemoveSources}
|
|
onUnmanageSource={unmanageSource}
|
|
onConvertKnownHost={convertKnownHostToHost}
|
|
onToggleConnectionLogSaved={toggleConnectionLogSaved}
|
|
onDeleteConnectionLog={deleteConnectionLog}
|
|
onClearUnsavedConnectionLogs={clearUnsavedConnectionLogs}
|
|
onRunSnippet={runSnippet}
|
|
onOpenLogView={openLogView}
|
|
showRecentHosts={settings.showRecentHosts}
|
|
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
|
|
navigateToSection={navigateToSection}
|
|
onNavigateToSectionHandled={() => setNavigateToSection(null)}
|
|
terminalSettings={terminalSettings}
|
|
/>
|
|
</VaultViewContainer>
|
|
|
|
<SftpViewMount
|
|
hosts={hosts}
|
|
keys={keys}
|
|
identities={identities}
|
|
knownHosts={effectiveKnownHosts}
|
|
proxyProfiles={proxyProfiles}
|
|
groupConfigs={groupConfigs}
|
|
updateHosts={updateHosts}
|
|
onAddKnownHost={handleAddKnownHost}
|
|
sftpDefaultViewMode={sftpDefaultViewMode}
|
|
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
|
sftpAutoSync={sftpAutoSync}
|
|
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
|
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
|
hotkeyScheme={hotkeyScheme}
|
|
keyBindings={keyBindings}
|
|
editorWordWrap={editorWordWrap}
|
|
setEditorWordWrap={setEditorWordWrap}
|
|
terminalSettings={terminalSettings}
|
|
/>
|
|
|
|
<TerminalLayerMount
|
|
hosts={hosts}
|
|
customGroups={customGroups}
|
|
groupConfigs={groupConfigs}
|
|
proxyProfiles={proxyProfiles}
|
|
keys={keys}
|
|
identities={identities}
|
|
snippets={snippets}
|
|
snippetPackages={snippetPackages}
|
|
sessions={sessions}
|
|
workspaces={workspaces}
|
|
knownHosts={effectiveKnownHosts}
|
|
draggingSessionId={draggingSessionId}
|
|
terminalTheme={currentTerminalTheme}
|
|
followAppTerminalTheme={followAppTerminalTheme}
|
|
accentMode={accentMode}
|
|
customAccent={customAccent}
|
|
terminalSettings={terminalSettings}
|
|
terminalFontFamilyId={terminalFontFamilyId}
|
|
fontSize={terminalFontSize}
|
|
hotkeyScheme={hotkeyScheme}
|
|
disableTerminalFontZoom={settings.disableTerminalFontZoom}
|
|
keyBindings={keyBindings}
|
|
onHotkeyAction={handleHotkeyAction}
|
|
onUpdateTerminalThemeId={setTerminalThemeId}
|
|
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
|
|
onUpdateTerminalFontSize={setTerminalFontSize}
|
|
onUpdateSessionFontSize={updateSessionFontSize}
|
|
onClearSessionFontSizeOverride={clearSessionFontSizeOverride}
|
|
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
|
|
onCloseSession={closeSession}
|
|
onUpdateSessionStatus={handleSessionStatusChange}
|
|
onUpdateHostDistro={updateHostDistro}
|
|
onUpdateHost={handleUpdateHostFromTerminal}
|
|
onAddKnownHost={handleAddKnownHost}
|
|
onCommandExecuted={(command, hostId, hostLabel, sessionId) => {
|
|
addShellHistoryEntry({ command, hostId, hostLabel, sessionId });
|
|
}}
|
|
shellHistory={shellHistory}
|
|
onTerminalDataCapture={handleTerminalDataCapture}
|
|
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
|
onAddSessionToWorkspace={addSessionToWorkspace}
|
|
onRequestAddToWorkspace={(workspaceId) =>
|
|
setAddToWorkspaceDialog({ mode: 'append', workspaceId })
|
|
}
|
|
onUpdateSplitSizes={updateSplitSizes}
|
|
onSetDraggingSessionId={setDraggingSessionId}
|
|
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
|
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
|
|
onReorderWorkspaceSessions={reorderWorkspaceSessions}
|
|
onReorderTabs={reorderWorkTabs}
|
|
onCopySession={copySessionWithCurrentShell}
|
|
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
|
onSplitSession={splitSessionWithCurrentShell}
|
|
onConnectToHost={handleConnectToHost}
|
|
onCreateLocalTerminal={handleCreateLocalTerminal}
|
|
isBroadcastEnabled={isBroadcastEnabled}
|
|
onToggleBroadcast={toggleBroadcast}
|
|
updateHosts={updateHosts}
|
|
updateSnippets={updateSnippets}
|
|
updateSnippetPackages={updateSnippetPackages}
|
|
sftpDefaultViewMode={sftpDefaultViewMode}
|
|
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
|
sftpAutoSync={sftpAutoSync}
|
|
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
|
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
|
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
|
|
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
|
|
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
|
|
editorWordWrap={editorWordWrap}
|
|
setEditorWordWrap={setEditorWordWrap}
|
|
sessionLogsEnabled={sessionLogsEnabled}
|
|
sessionLogsDir={sessionLogsDir}
|
|
sessionLogsFormat={sessionLogsFormat}
|
|
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
|
|
sshDebugLogsEnabled={sshDebugLogsEnabled}
|
|
showHostTreeSidebar={settings.showHostTreeSidebar}
|
|
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
|
toggleSidePanelRef={toggleSidePanelRef}
|
|
onStartSessionRename={startSessionRename}
|
|
onSubmitSessionRename={submitSessionRename}
|
|
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
|
/>
|
|
|
|
{/* Log Views - readonly terminal replays */}
|
|
{logViews.map(logView => {
|
|
// Get the latest log data from connectionLogs to reflect updates
|
|
const latestLog = connectionLogs.find(l => l.id === logView.connectionLogId) || logView.log;
|
|
return (
|
|
<LogViewWrapper
|
|
key={logView.id}
|
|
logView={{ ...logView, log: latestLog }}
|
|
defaultTerminalTheme={currentTerminalTheme}
|
|
defaultFontSize={terminalFontSize}
|
|
onClose={() => closeLogView(logView.id)}
|
|
onUpdateLog={updateConnectionLog}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
|
|
{editorTabs.map((tab) => (
|
|
<TextEditorTabView
|
|
key={tab.id}
|
|
tabId={tab.id}
|
|
hotkeyScheme={hotkeyScheme}
|
|
keyBindings={keyBindings}
|
|
hostById={hostById}
|
|
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Global "quick add / edit snippet" dialog, triggered by the
|
|
netcatty:snippets:add and :edit window events (from ScriptsSidePanel
|
|
"+" button and right-click menu). Delete is handled by a sibling
|
|
useEffect above — it does not need a dialog. */}
|
|
<QuickAddSnippetDialog
|
|
snippets={snippets}
|
|
packages={snippetPackages}
|
|
onCreateSnippet={(snippet) => updateSnippets([...snippets, snippet])}
|
|
onUpdateSnippet={(snippet) =>
|
|
updateSnippets(snippets.map((s) => (s.id === snippet.id ? snippet : s)))
|
|
}
|
|
onCreatePackage={(pkg) =>
|
|
updateSnippetPackages(Array.from(new Set([...snippetPackages, pkg])))
|
|
}
|
|
/>
|
|
|
|
{/* Root-mounted AddToWorkspaceDialog — triggered by the focus-mode
|
|
"+" button (mode='append') or QuickSwitcher's "New Workspace"
|
|
button (mode='create'). Single instance so dialog state and
|
|
styling stay consistent across entry points. */}
|
|
{addToWorkspaceDialog && (
|
|
<AddToWorkspaceDialog
|
|
open
|
|
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
|
|
// Filter serial hosts only in append mode — appendHostToWorkspace
|
|
// has no serial code path. Create mode goes through
|
|
// createWorkspaceFromTargets, which builds a SerialConfig-backed
|
|
// session for serial hosts, so those should remain pickable.
|
|
hosts={addToWorkspaceDialog.mode === 'append'
|
|
? hosts.filter((h) => h.protocol !== 'serial')
|
|
: hosts}
|
|
workspaceTitle={
|
|
addToWorkspaceDialog.mode === 'append'
|
|
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
|
|
: 'New Workspace'
|
|
}
|
|
onAdd={(targets) => {
|
|
if (addToWorkspaceDialog.mode === 'append') {
|
|
// Match the workspace root's current split direction so
|
|
// the new panes peer the existing siblings instead of
|
|
// wrapping the whole tree into one side of a fresh split
|
|
// (which would happen if we always passed the helper's
|
|
// default 'vertical').
|
|
const ws = workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId);
|
|
const rootDir = ws && ws.root.type === 'split' ? ws.root.direction : 'vertical';
|
|
for (const target of targets) {
|
|
if (target.kind === 'local') {
|
|
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
|
|
} else {
|
|
appendHostToWorkspace(addToWorkspaceDialog.workspaceId, target.host, rootDir);
|
|
}
|
|
}
|
|
} else {
|
|
createWorkspaceFromTargets(targets);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{isQuickSwitcherOpen && (
|
|
<Suspense fallback={null}>
|
|
<LazyQuickSwitcher
|
|
isOpen={isQuickSwitcherOpen}
|
|
query={quickSearch}
|
|
results={quickResults}
|
|
sessions={sessions}
|
|
workspaces={workspaces}
|
|
showSftpTab={settings.showSftpTab}
|
|
onQueryChange={setQuickSearch}
|
|
onSelect={handleHostConnectWithProtocolCheck}
|
|
onSelectTab={(tabId) => {
|
|
setActiveTabId(tabId);
|
|
setIsQuickSwitcherOpen(false);
|
|
setQuickSearch('');
|
|
}}
|
|
onCreateLocalTerminal={(shell) => {
|
|
handleCreateLocalTerminal(shell);
|
|
setIsQuickSwitcherOpen(false);
|
|
setQuickSearch('');
|
|
}}
|
|
onCreateWorkspace={() => {
|
|
setIsQuickSwitcherOpen(false);
|
|
setQuickSearch('');
|
|
setAddToWorkspaceDialog({ mode: 'create' });
|
|
}}
|
|
onClose={() => {
|
|
setIsQuickSwitcherOpen(false);
|
|
setQuickSearch('');
|
|
}}
|
|
keyBindings={keyBindings}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
|
|
<Dialog open={!!sessionRenameTarget} onOpenChange={(open) => {
|
|
if (!open) {
|
|
resetSessionRename();
|
|
}
|
|
}}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('dialog.renameSession.title')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-2 py-2">
|
|
<Label htmlFor="session-name">{t('field.name')}</Label>
|
|
<Input
|
|
id="session-name"
|
|
value={sessionRenameValue}
|
|
onChange={(e) => setSessionRenameValue(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') submitSessionRename(); }}
|
|
autoFocus
|
|
placeholder={t('placeholder.sessionName')}
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={resetSessionRename}>{t('common.cancel')}</Button>
|
|
<Button onClick={submitSessionRename} disabled={!sessionRenameValue.trim()}>{t('common.save')}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={!!workspaceRenameTarget} onOpenChange={(open) => {
|
|
if (!open) {
|
|
resetWorkspaceRename();
|
|
}
|
|
}}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('dialog.renameWorkspace.title')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-2 py-2">
|
|
<Label htmlFor="workspace-name">{t('field.name')}</Label>
|
|
<Input
|
|
id="workspace-name"
|
|
value={workspaceRenameValue}
|
|
onChange={(e) => setWorkspaceRenameValue(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') submitWorkspaceRename(); }}
|
|
autoFocus
|
|
placeholder={t('placeholder.workspaceName')}
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={resetWorkspaceRename}>{t('common.cancel')}</Button>
|
|
<Button onClick={submitWorkspaceRename} disabled={!workspaceRenameValue.trim()}>{t('common.save')}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{isCreateWorkspaceOpen && (
|
|
<Suspense fallback={null}>
|
|
<LazyCreateWorkspaceDialog
|
|
isOpen={isCreateWorkspaceOpen}
|
|
onClose={() => setIsCreateWorkspaceOpen(false)}
|
|
hosts={hosts}
|
|
onCreate={createWorkspaceWithHosts}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
|
|
{/* Protocol Select Dialog for QuickSwitcher */}
|
|
{protocolSelectHost && (
|
|
<Suspense fallback={null}>
|
|
<LazyProtocolSelectDialog
|
|
host={protocolSelectHost}
|
|
onSelect={handleProtocolSelect}
|
|
onCancel={() => setProtocolSelectHost(null)}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
|
|
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) - processes queue */}
|
|
<KeyboardInteractiveModal
|
|
request={keyboardInteractiveQueue[0] || null}
|
|
onSubmit={handleKeyboardInteractiveSubmit}
|
|
onCancel={handleKeyboardInteractiveCancel}
|
|
/>
|
|
{/* Indicator when more 2FA requests are pending */}
|
|
{keyboardInteractiveQueue.length > 1 && (
|
|
<div className="fixed bottom-4 right-4 z-50 bg-muted/90 backdrop-blur-sm text-sm px-3 py-1.5 rounded-full border shadow-sm">
|
|
{keyboardInteractiveQueue.length - 1} more pending
|
|
</div>
|
|
)}
|
|
|
|
{/* Global Passphrase Modal for encrypted SSH keys */}
|
|
<PassphraseModal
|
|
request={passphraseQueue[0] || null}
|
|
onSubmit={handlePassphraseSubmit}
|
|
onCancel={handlePassphraseCancel}
|
|
onSkip={handlePassphraseSkip}
|
|
/>
|
|
|
|
{/* Empty vault vs cloud data confirmation dialog (#679).
|
|
This dialog intentionally cannot be dismissed — the user MUST
|
|
choose "Restore" or "Keep Empty" before the sync flow can
|
|
proceed. hideCloseButton removes the X button, onOpenChange
|
|
is a no-op so ESC also does nothing, and onInteractOutside
|
|
prevents click-away. */}
|
|
<Dialog open={!!emptyVaultConflict} onOpenChange={() => { /* intentionally non-dismissable */ }}>
|
|
<DialogContent className="max-w-md" hideCloseButton onInteractOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}>
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
|
{t('sync.autoSync.emptyVaultConflict.title')}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t('sync.autoSync.emptyVaultConflict.description')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{emptyVaultConflict && (
|
|
<div className="bg-muted/30 rounded-lg p-3 text-sm">
|
|
<div className="font-medium text-muted-foreground mb-1">{t('sync.autoSync.emptyVaultConflict.cloudLabel')}</div>
|
|
<div>{t('sync.autoSync.emptyVaultConflict.cloudSummary', {
|
|
hosts: emptyVaultConflict.hostCount,
|
|
keys: emptyVaultConflict.keyCount,
|
|
snippets: emptyVaultConflict.snippetCount,
|
|
proxyProfiles: emptyVaultConflict.proxyProfileCount,
|
|
})}</div>
|
|
</div>
|
|
)}
|
|
<DialogFooter className="flex-col gap-2 sm:flex-col">
|
|
<Button
|
|
onClick={() => resolveEmptyVaultConflict('restore')}
|
|
className="w-full justify-start gap-2"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
<span>
|
|
{t('sync.autoSync.emptyVaultConflict.restore')}
|
|
<span className="text-xs opacity-70 ml-1">— {t('sync.autoSync.emptyVaultConflict.restoreDesc')}</span>
|
|
</span>
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => resolveEmptyVaultConflict('keep-empty')}
|
|
className="w-full justify-start gap-2"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
<span>
|
|
{t('sync.autoSync.emptyVaultConflict.keepEmpty')}
|
|
<span className="text-xs opacity-70 ml-1">— {t('sync.autoSync.emptyVaultConflict.keepEmptyDesc')}</span>
|
|
</span>
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}}
|
|
</UnsavedChangesProvider>
|
|
</SnippetExecutionProvider>
|
|
);
|
|
}
|