Files
Netcatty/application/state/useSessionState.ts
陈大猫 f5c3302329 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>
2026-06-14 01:30:44 +08:00

1021 lines
36 KiB
TypeScript

import { MouseEvent,useCallback,useMemo,useRef,useState } from 'react';
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
import { addLogView, getLogViewTabId, removeLogView, type LogView } from './logViewState';
import { createHostTerminalSession, createLocalTerminalSession, createSerialTerminalSession, type LocalTerminalOptions } from './sessionFactories';
import {
appendPaneToWorkspaceRoot,
collectSessionIds,
createWorkspaceFromSessions as createWorkspaceEntity,
createWorkspaceFromSessionIds,
FocusDirection,
getNextFocusSessionId,
insertPaneIntoWorkspace,
pruneWorkspaceNode,
reorderWorkspaceFocusSessionOrder,
SplitDirection,
SplitHint,
updateWorkspaceSplitSizes,
} from '../../domain/workspace';
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
import { activeTabStore } from './activeTabStore';
import {
closeSessionWorkspaceLayoutState,
detachSessionFromWorkspaceState,
replaceDissolvedWorkspaceTabOrder,
} from './sessionWorkspaceDetach';
import {
createCopiedTerminalSessionClone,
createSplitTerminalSessionClone,
} from './terminalConnectionReuse';
export const useSessionState = () => {
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
// Latest workspaces snapshot for synchronous existence checks outside
// setWorkspaces updaters — React doesn't guarantee updaters run
// synchronously, so relying on a flag flipped inside them to decide
// whether to also call setSessions is racy and can leave orphan panes.
const workspacesRef = useRef(workspaces);
workspacesRef.current = workspaces;
// activeTabId is now managed by external store - components subscribe directly
const setActiveTabId = activeTabStore.setActiveTabId;
const [draggingSessionId, setDraggingSessionId] = useState<string | null>(null);
const [sessionRenameTarget, setSessionRenameTarget] = useState<TerminalSession | null>(null);
const [sessionRenameValue, setSessionRenameValue] = useState('');
const [workspaceRenameTarget, setWorkspaceRenameTarget] = useState<Workspace | null>(null);
const [workspaceRenameValue, setWorkspaceRenameValue] = useState('');
// Tab order: stores ordered list of tab IDs (orphan session IDs and workspace IDs)
const [tabOrder, setTabOrder] = useState<string[]>([]);
// Broadcast mode: stores workspace IDs that have broadcast enabled
const [broadcastWorkspaceIds, setBroadcastWorkspaceIds] = useState<Set<string>>(new Set());
// Log views: stores open log replay tabs
const [logViews, setLogViews] = useState<LogView[]>([]);
const createLocalTerminal = useCallback((options?: LocalTerminalOptions) => {
const sessionId = crypto.randomUUID();
setSessions(prev => [...prev, createLocalTerminalSession(sessionId, options)]);
setActiveTabId(sessionId);
return sessionId;
}, [setActiveTabId]);
const createSerialSession = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const sessionId = crypto.randomUUID();
setSessions(prev => [...prev, createSerialTerminalSession(sessionId, config, options)]);
setActiveTabId(sessionId);
return sessionId;
}, [setActiveTabId]);
const connectToHost = useCallback((host: Host) => {
const newSession = createHostTerminalSession(crypto.randomUUID(), host);
setSessions(prev => [...prev, newSession]);
setActiveTabId(newSession.id);
return newSession.id;
}, [setActiveTabId]);
const updateSessionStatus = useCallback((sessionId: string, status: TerminalSession['status']) => {
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
}, []);
const updateSessionFontSize = useCallback((sessionId: string, fontSize: number) => {
setSessions(prev => prev.map(s => (
s.id === sessionId ? { ...s, fontSize, fontSizeOverride: true } : s
)));
}, []);
const clearSessionFontSizeOverride = useCallback((sessionId: string) => {
setSessions(prev => prev.map(s => (
s.id === sessionId ? clearSessionFontSizeOverrideFields(s) : s
)));
}, []);
const closeWorkspace = useCallback((workspaceId: string) => {
setWorkspaces(prevWorkspaces => {
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
setSessions(prevSessions => prevSessions.filter(s => s.workspaceId !== workspaceId));
const currentActiveTabId = activeTabStore.getActiveTabId();
if (currentActiveTabId === workspaceId) {
if (remainingWorkspaces.length > 0) {
setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id);
} else {
setActiveTabId('vault');
}
}
return remainingWorkspaces;
});
}, [setActiveTabId]);
const closeSession = useCallback((sessionId: string, e?: MouseEvent) => {
e?.stopPropagation();
// Pre-compute outside the setSessions updater so we don't depend on React
// having run the updater by the time we queue the microtask. React 18+ does
// not guarantee updater execution timing under concurrent scheduling.
const sessionBeingClosed = sessions.find(s => s.id === sessionId);
const workspaceIdToMaybeClose =
sessionBeingClosed?.workspaceId &&
sessions.every(s => s.id === sessionId || s.workspaceId !== sessionBeingClosed.workspaceId)
? sessionBeingClosed.workspaceId
: undefined;
setSessions(prevSessions => {
const targetSession = prevSessions.find(s => s.id === sessionId);
const wsId = targetSession?.workspaceId;
setWorkspaces(prevWorkspaces => {
const {
workspaces: nextWorkspaces,
removedWorkspaceId,
dissolvedWorkspaceId,
lastRemainingSessionId,
} = closeSessionWorkspaceLayoutState(prevWorkspaces, wsId, sessionId);
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
const fallbackSolo = remainingSessions.filter(s => !s.workspaceId).slice(-1)[0];
const currentActiveTabId = activeTabStore.getActiveTabId();
const getFallback = () => {
if (lastRemainingSessionId) return lastRemainingSessionId;
if (fallbackWorkspace) return fallbackWorkspace.id;
if (fallbackSolo) return fallbackSolo.id;
return 'vault';
};
if (dissolvedWorkspaceId && lastRemainingSessionId) {
setTabOrder(prevTabOrder => replaceDissolvedWorkspaceTabOrder(
prevTabOrder,
dissolvedWorkspaceId,
[lastRemainingSessionId],
));
}
if (dissolvedWorkspaceId && currentActiveTabId === dissolvedWorkspaceId) {
setActiveTabId(getFallback());
} else if (currentActiveTabId === sessionId) {
setActiveTabId(getFallback());
} else if (removedWorkspaceId && currentActiveTabId === removedWorkspaceId) {
setActiveTabId(getFallback());
} else if (wsId && currentActiveTabId === wsId && !nextWorkspaces.find(w => w.id === wsId)) {
setActiveTabId(getFallback());
}
return nextWorkspaces;
});
// Check if we need to dissolve a workspace (convert remaining session to orphan)
if (targetSession?.workspaceId) {
const ws = workspaces.find(w => w.id === targetSession.workspaceId);
if (ws) {
const pruned = pruneWorkspaceNode(ws.root, sessionId);
if (pruned) {
const remainingSessionIds = collectSessionIds(pruned);
if (remainingSessionIds.length === 1) {
// Dissolve: remove workspaceId from the remaining session
return prevSessions
.filter(s => s.id !== sessionId)
.map(s => remainingSessionIds.includes(s.id) ? { ...s, workspaceId: undefined } : s);
}
}
}
}
return prevSessions.filter(s => s.id !== sessionId);
});
if (workspaceIdToMaybeClose) {
queueMicrotask(() => closeWorkspace(workspaceIdToMaybeClose!));
}
}, [sessions, workspaces, setActiveTabId, closeWorkspace]);
const startSessionRename = useCallback((sessionId: string) => {
setSessions(prevSessions => {
const target = prevSessions.find(s => s.id === sessionId);
if (target) {
setSessionRenameTarget(target);
setSessionRenameValue(target.customName || target.hostLabel);
}
return prevSessions;
});
}, []);
const renameSessionInline = useCallback((sessionId: string, name: string) => {
const trimmed = name.trim();
if (!trimmed) return;
setSessions(prev => prev.map(s => (
s.id === sessionId ? { ...s, customName: trimmed, hostLabel: trimmed } : s
)));
}, []);
const submitSessionRename = useCallback((sessionId?: string, name?: string) => {
if (sessionId !== undefined && name !== undefined) {
const trimmed = name.trim();
if (!trimmed) return;
setSessions(prev => prev.map(s => (
s.id === sessionId ? { ...s, customName: trimmed, hostLabel: trimmed } : s
)));
return;
}
setSessionRenameValue(prevValue => {
const trimmed = prevValue.trim();
if (!trimmed) return prevValue;
setSessionRenameTarget(prevTarget => {
if (!prevTarget) return prevTarget;
setSessions(prev => prev.map(s => (
s.id === prevTarget.id ? { ...s, customName: trimmed, hostLabel: trimmed } : s
)));
return null;
});
return '';
});
}, []);
const resetSessionRename = useCallback(() => {
setSessionRenameTarget(null);
setSessionRenameValue('');
}, []);
const startWorkspaceRename = useCallback((workspaceId: string) => {
setWorkspaces(prevWorkspaces => {
const target = prevWorkspaces.find(w => w.id === workspaceId);
if (target) {
setWorkspaceRenameTarget(target);
setWorkspaceRenameValue(target.title);
}
return prevWorkspaces;
});
}, []);
const submitWorkspaceRename = useCallback(() => {
setWorkspaceRenameValue(prevValue => {
const name = prevValue.trim();
if (!name) return prevValue;
setWorkspaceRenameTarget(prevTarget => {
if (!prevTarget) return prevTarget;
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: name } : w));
return null;
});
return '';
});
}, []);
const resetWorkspaceRename = useCallback(() => {
setWorkspaceRenameTarget(null);
setWorkspaceRenameValue('');
}, []);
const createWorkspaceWithHosts = useCallback((name: string, hosts: Host[]) => {
if (hosts.length === 0) return;
// Create sessions for each host
const newSessions: TerminalSession[] = hosts.map(host => {
// Handle serial hosts specially
if (host.protocol === 'serial') {
const serialConfig: SerialConfig = host.serialConfig || {
path: host.hostname,
baudRate: host.port || 115200,
dataBits: 8,
stopBits: 1,
parity: 'none',
flowControl: 'none',
localEcho: false,
lineMode: false,
};
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
return {
id: crypto.randomUUID(),
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: serialConfig.path,
username: '',
status: 'connecting',
protocol: 'serial',
serialConfig: serialConfig,
charset: host.charset,
};
}
return {
id: crypto.randomUUID(),
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
status: 'connecting',
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
};
});
const sessionIds = newSessions.map(s => s.id);
// Create workspace
const workspace = createWorkspaceFromSessionIds(sessionIds, {
title: name,
viewMode: 'split',
});
// Assign workspaceId to sessions
const sessionsWithWorkspace = newSessions.map(s => ({
...s,
workspaceId: workspace.id
}));
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
setWorkspaces(prev => [...prev, workspace]);
setActiveTabId(workspace.id);
}, [setActiveTabId]);
// Like createWorkspaceWithHosts but supports mixed targets — each
// entry is either an SSH host or a local terminal. Used by the
// "New Workspace" flow in QuickSwitcher.
type WorkspaceTarget =
| { kind: 'local'; shellType?: TerminalSession['shellType']; shell?: string; shellArgs?: string[]; shellName?: string; shellIcon?: string }
| { kind: 'host'; host: Host };
const createWorkspaceFromTargets = useCallback((targets: WorkspaceTarget[], name: string = 'Workspace'): string | null => {
if (targets.length === 0) return null;
const newSessions: TerminalSession[] = targets.map((target) => {
if (target.kind === 'local') {
const sessionId = crypto.randomUUID();
return {
id: sessionId,
hostId: `local-${sessionId}`,
hostLabel: target.shellName || 'Local Terminal',
hostname: 'localhost',
username: 'local',
status: 'connecting',
protocol: 'local',
shellType: target.shellType,
localShell: target.shell,
localShellArgs: target.shellArgs,
localShellName: target.shellName,
localShellIcon: target.shellIcon,
};
}
const host = target.host;
if (host.protocol === 'serial') {
const serialConfig: SerialConfig = host.serialConfig || {
path: host.hostname,
baudRate: host.port || 115200,
dataBits: 8,
stopBits: 1,
parity: 'none',
flowControl: 'none',
localEcho: false,
lineMode: false,
};
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
return {
id: crypto.randomUUID(),
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: serialConfig.path,
username: '',
status: 'connecting',
protocol: 'serial',
serialConfig,
charset: host.charset,
};
}
return {
id: crypto.randomUUID(),
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
status: 'connecting',
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
};
});
const sessionIds = newSessions.map((s) => s.id);
// Default to focus-mode (sidebar layout) regardless of target
// count — matches the intent behind the QuickSwitcher "New
// Workspace" flow, which the user expects to land in focus view.
const workspace = createWorkspaceFromSessionIds(sessionIds, {
title: name,
viewMode: 'focus',
});
const sessionsWithWorkspace = newSessions.map((s) => ({ ...s, workspaceId: workspace.id }));
setSessions((prev) => [...prev, ...sessionsWithWorkspace]);
setWorkspaces((prev) => [...prev, workspace]);
setActiveTabId(workspace.id);
return workspace.id;
}, [setActiveTabId]);
const createWorkspaceFromSessions = useCallback((
baseSessionId: string,
joiningSessionId: string,
hint: SplitHint
) => {
if (!hint || baseSessionId === joiningSessionId) return;
setSessions(prevSessions => {
const base = prevSessions.find(s => s.id === baseSessionId);
const joining = prevSessions.find(s => s.id === joiningSessionId);
if (!base || !joining || base.workspaceId || joining.workspaceId) return prevSessions;
const newWorkspace = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
setWorkspaces(prev => [...prev, newWorkspace]);
setActiveTabId(newWorkspace.id);
return prevSessions.map(s => {
if (s.id === baseSessionId || s.id === joiningSessionId) {
return { ...s, workspaceId: newWorkspace.id };
}
return s;
});
});
}, [setActiveTabId]);
const addSessionToWorkspace = useCallback((
workspaceId: string,
sessionId: string,
hint: SplitHint
) => {
if (!hint) return;
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session || session.workspaceId) return prevSessions;
setWorkspaces(prevWorkspaces => {
const targetWorkspace = prevWorkspaces.find(w => w.id === workspaceId);
if (!targetWorkspace) return prevWorkspaces;
return prevWorkspaces.map(ws => {
if (ws.id !== workspaceId) return ws;
return { ...ws, root: insertPaneIntoWorkspace(ws.root, sessionId, hint) };
});
});
setActiveTabId(workspaceId);
return prevSessions.map(s => s.id === sessionId ? { ...s, workspaceId } : s);
});
}, [setActiveTabId]);
// Add a host into an existing workspace by creating a new session for
// that host and appending it as the last pane at the workspace root.
// Sibling sizes are rebalanced equally by appendPaneToWorkspaceRoot.
// Unlike addSessionToWorkspace (which takes a pre-created orphan
// session and a SplitHint), this is atomic — the new session is born
// already bound to the target workspace and focused.
const appendHostToWorkspace = useCallback((
workspaceId: string,
host: Host,
direction: SplitDirection = 'vertical',
): string | null => {
// Serial hosts use a different session constructor; they currently
// only enter workspaces via createSerialSession + drag, so reject
// them here to avoid a partially-constructed session.
if (host.protocol === 'serial') return null;
// Cheap early-exit using the ref when the workspace is clearly
// absent. The authoritative check lives inside the setWorkspaces
// updater below so we also cover the concurrent-close race.
if (!workspacesRef.current.some(w => w.id === workspaceId)) return null;
const newSessionId = crypto.randomUUID();
const newSession: TerminalSession = {
id: newSessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
status: 'connecting',
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
workspaceId,
};
// Nest setSessions + setActiveTabId inside the setWorkspaces updater
// so we only commit the session when the workspace update actually
// matched — otherwise a concurrent closeWorkspace between the ref
// check and the updater firing would leave an orphan session with a
// workspaceId pointing at nothing, and active tab would jump to a
// closed id. The inner setSessions is idempotent (id dedupe) so
// StrictMode's dev-time double-invoke does not duplicate the row.
setWorkspaces(prev => {
const target = prev.find(w => w.id === workspaceId);
if (!target) return prev;
setSessions(s => s.some(x => x.id === newSessionId) ? s : [...s, newSession]);
setActiveTabId(workspaceId);
return prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return {
...ws,
root: appendPaneToWorkspaceRoot(ws.root, newSessionId, direction),
focusedSessionId: newSessionId,
};
});
});
return newSessionId;
}, [setActiveTabId]);
// Atomic "append a local terminal pane" — mirror of appendHostToWorkspace
// but constructs a local-protocol session instead of an SSH one.
const appendLocalTerminalToWorkspace = useCallback((
workspaceId: string,
options?: {
shellType?: TerminalSession['shellType'];
shell?: string;
shellArgs?: string[];
shellName?: string;
shellIcon?: string;
},
direction: SplitDirection = 'vertical',
): string | null => {
// Same pattern as appendHostToWorkspace — ref guard + authoritative
// inside-updater match to cover concurrent closeWorkspace.
if (!workspacesRef.current.some(w => w.id === workspaceId)) return null;
const newSessionId = crypto.randomUUID();
const localHostId = `local-${newSessionId}`;
const newSession: TerminalSession = {
id: newSessionId,
hostId: localHostId,
hostLabel: options?.shellName || 'Local Terminal',
hostname: 'localhost',
username: 'local',
status: 'connecting',
protocol: 'local',
shellType: options?.shellType,
localShell: options?.shell,
localShellArgs: options?.shellArgs,
localShellName: options?.shellName,
localShellIcon: options?.shellIcon,
workspaceId,
};
setWorkspaces(prev => {
const target = prev.find(w => w.id === workspaceId);
if (!target) return prev;
setSessions(s => s.some(x => x.id === newSessionId) ? s : [...s, newSession]);
setActiveTabId(workspaceId);
return prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return {
...ws,
root: appendPaneToWorkspaceRoot(ws.root, newSessionId, direction),
focusedSessionId: newSessionId,
};
});
});
return newSessionId;
}, [setActiveTabId]);
const updateSplitSizes = useCallback((workspaceId: string, splitId: string, sizes: number[]) => {
setWorkspaces(prev => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return { ...ws, root: updateWorkspaceSplitSizes(ws.root, splitId, sizes) };
}));
}, []);
// Split a session to create a workspace with the same host connection
// direction: 'horizontal' = split top/bottom, 'vertical' = split left/right
const splitSession = useCallback((
sessionId: string,
direction: SplitDirection,
options?: {
localShellType?: TerminalSession['shellType'];
},
) => {
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session) return prevSessions;
// If session is already in a workspace, split within that workspace
if (session.workspaceId) {
// Create a new session with the same host
const newSession = createSplitTerminalSessionClone(session, {
id: crypto.randomUUID(),
localShellType: options?.localShellType,
workspaceId: session.workspaceId,
});
// Add pane to existing workspace
const hint: SplitHint = {
direction,
position: direction === 'horizontal' ? 'bottom' : 'right',
targetSessionId: sessionId,
};
setWorkspaces(prevWorkspaces => {
return prevWorkspaces.map(ws => {
if (ws.id !== session.workspaceId) return ws;
return { ...ws, root: insertPaneIntoWorkspace(ws.root, newSession.id, hint) };
});
});
return [...prevSessions, newSession];
}
// Session is standalone - create a new workspace
const newSession = createSplitTerminalSessionClone(session, {
id: crypto.randomUUID(),
localShellType: options?.localShellType,
});
const hint: SplitHint = {
direction,
position: direction === 'horizontal' ? 'bottom' : 'right',
};
const newWorkspace = createWorkspaceEntity(sessionId, newSession.id, hint);
setWorkspaces(prev => [...prev, newWorkspace]);
setActiveTabId(newWorkspace.id);
return prevSessions.map(s => {
if (s.id === sessionId) {
return { ...s, workspaceId: newWorkspace.id };
}
return s;
}).concat({ ...newSession, workspaceId: newWorkspace.id });
});
}, [setActiveTabId]);
// Toggle workspace view mode between split and focus
const toggleWorkspaceViewMode = useCallback((workspaceId: string) => {
setWorkspaces(prev => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
const currentMode = ws.viewMode || 'split';
const newMode: WorkspaceViewMode = currentMode === 'split' ? 'focus' : 'split';
// If switching to focus mode and no focused session, pick the first one
let focusedSessionId = ws.focusedSessionId;
if (newMode === 'focus' && !focusedSessionId) {
const sessionIds = collectSessionIds(ws.root);
focusedSessionId = sessionIds[0];
}
return { ...ws, viewMode: newMode, focusedSessionId };
}));
}, []);
// Set the focused session in a workspace (for focus mode)
const setWorkspaceFocusedSession = useCallback((workspaceId: string, sessionId: string) => {
setWorkspaces(prev => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return { ...ws, focusedSessionId: sessionId };
}));
}, []);
const reorderWorkspaceSessions = useCallback((
workspaceId: string,
draggedSessionId: string,
targetSessionId: string,
position: 'before' | 'after' = 'before',
) => {
setWorkspaces(prev => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return {
...ws,
focusSessionOrder: reorderWorkspaceFocusSessionOrder(
ws.root,
ws.focusSessionOrder,
draggedSessionId,
targetSessionId,
position,
),
};
}));
}, []);
// Move focus between panes in a workspace
const moveFocusInWorkspace = useCallback((workspaceId: string, direction: FocusDirection): boolean => {
const workspace = workspaces.find(w => w.id === workspaceId);
if (!workspace) {
return false;
}
// Get current focused session, or first session if none focused
const sessionIds = collectSessionIds(workspace.root);
const currentFocused = workspace.focusedSessionId || sessionIds[0];
if (!currentFocused) {
return false;
}
// Find the next session in the given direction
const nextSessionId = getNextFocusSessionId(workspace.root, currentFocused, direction);
if (!nextSessionId) {
return false;
}
// Update focused session
setWorkspaces(prev => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return { ...ws, focusedSessionId: nextSessionId };
}));
return true;
}, [workspaces]);
// Run a snippet on multiple target hosts - creates a focus mode workspace
const runSnippet = useCallback((snippet: Snippet, targetHosts: Host[], commandOverride?: string) => {
if (targetHosts.length === 0) return;
const resolvedCommand = commandOverride ?? snippet.command;
// Create sessions for each target host
const newSessions: TerminalSession[] = targetHosts.map(host => ({
id: crypto.randomUUID(),
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
status: 'connecting' as const,
charset: host.charset,
// workspaceId will be set after workspace is created
}));
const sessionIds = newSessions.map(s => s.id);
// Create a focus mode workspace
const workspace = createWorkspaceFromSessionIds(sessionIds, {
title: snippet.label,
viewMode: 'focus',
snippetId: snippet.id,
});
// Update sessions with workspaceId
const sessionsWithWorkspace = newSessions.map(s => ({
...s,
workspaceId: workspace.id,
// Store the command to run after connection
startupCommand: resolvedCommand,
noAutoRun: snippet.noAutoRun,
}));
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
setWorkspaces(prev => [...prev, workspace]);
setActiveTabId(workspace.id);
}, [setActiveTabId]);
const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId), [sessions]);
const openLogView = useCallback((log: ConnectionLog) => {
const tabId = getLogViewTabId(log);
setLogViews(prev => addLogView(prev, log));
setActiveTabId(tabId);
}, [setActiveTabId]);
const closeLogView = useCallback((logViewId: string) => {
setLogViews(prev => {
const updated = removeLogView(prev, logViewId);
if (activeTabStore.getActiveTabId() === logViewId) {
setActiveTabId(updated.length > 0 ? updated[updated.length - 1].id : 'vault');
}
return updated;
});
}, [setActiveTabId]);
// Copy a session - creates a new session with the same host connection
const copySession = useCallback((sessionId: string, options?: {
localShellType?: TerminalSession['shellType'];
}) => {
// Pre-allocate the new id outside the updater so StrictMode's
// double-invocation of the functional updater doesn't mint two ids.
const newSessionId = crypto.randomUUID();
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
// Source may have been closed between the user's action and this
// update running; in that case skip entirely — do NOT switch the
// active tab or insert into tabOrder, which would leave dangling ids.
if (!session) return prevSessions;
const newSession = createCopiedTerminalSessionClone(session, {
id: newSessionId,
localShellType: options?.localShellType,
});
// Schedule the activeTab + tabOrder updates only when creation
// actually happens. These nested setStates are idempotent, so
// StrictMode's double-invocation is harmless.
setActiveTabId(newSessionId);
setTabOrder(prevTabOrder => {
// Fast path: source is already tracked in tabOrder — splice directly.
const directIdx = prevTabOrder.indexOf(sessionId);
if (directIdx !== -1) {
const next = [...prevTabOrder];
next.splice(directIdx + 1, 0, newSessionId);
return next;
}
// Fallback: source is only in the derived tab collections. Rebuild the
// effective order (same pattern as reorderTabs) to locate its position.
const allTabIds = [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIdSet = new Set(allTabIds);
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
const currentOrder = [...orderedIds, ...newIds];
const sourceIdx = currentOrder.indexOf(sessionId);
if (sourceIdx === -1) return [...prevTabOrder, newSessionId];
const next = [...currentOrder];
next.splice(sourceIdx + 1, 0, newSessionId);
return next;
});
return [...prevSessions, newSession];
});
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
const createSessionFromCloneSource = useCallback((sourceSession: TerminalSession, options?: {
localShellType?: TerminalSession['shellType'];
}) => {
const newSessionId = crypto.randomUUID();
const newSession = createCopiedTerminalSessionClone(sourceSession, {
id: newSessionId,
localShellType: options?.localShellType,
});
delete newSession.workspaceId;
setSessions(prevSessions => {
if (prevSessions.some(session => session.id === newSessionId)) return prevSessions;
return [...prevSessions, newSession];
});
setTabOrder(prevTabOrder => [...prevTabOrder, newSessionId]);
setActiveTabId(newSessionId);
return newSessionId;
}, [setActiveTabId]);
// Toggle broadcast mode for a workspace
const toggleBroadcast = useCallback((workspaceId: string) => {
setBroadcastWorkspaceIds(prev => {
const next = new Set(prev);
if (next.has(workspaceId)) {
next.delete(workspaceId);
} else {
next.add(workspaceId);
}
return next;
});
}, []);
// Check if a workspace has broadcast enabled
const isBroadcastEnabled = useCallback((workspaceId: string) => {
return broadcastWorkspaceIds.has(workspaceId);
}, [broadcastWorkspaceIds]);
const baseWorkTabIds = useMemo(() => [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
], [orphanSessions, workspaces, logViews]);
const getOrderedWorkTabs = useCallback((additionalTabIds: readonly string[] = []) => {
const allTabIds = [...baseWorkTabIds, ...additionalTabIds];
return buildOrderedWorkTabIds(tabOrder, allTabIds);
}, [baseWorkTabIds, tabOrder]);
// Get ordered tabs: combines orphan sessions, workspaces, and log views in the custom order
const orderedTabs = useMemo(
() => 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((
draggedId: string,
targetId: string,
position: 'before' | 'after' = 'before',
additionalTabIds: readonly string[] = [],
) => {
if (draggedId === targetId) return;
setTabOrder(prevTabOrder => reorderWorkTabIds(
prevTabOrder,
[...baseWorkTabIds, ...additionalTabIds],
draggedId,
targetId,
position,
));
}, [baseWorkTabIds]);
return {
sessions,
workspaces,
// activeTabId removed - components should subscribe via useActiveTabId() from activeTabStore
setActiveTabId,
draggingSessionId,
setDraggingSessionId,
sessionRenameTarget,
sessionRenameValue,
setSessionRenameValue,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
workspaceRenameTarget,
workspaceRenameValue,
setWorkspaceRenameValue,
startWorkspaceRename,
submitWorkspaceRename,
resetWorkspaceRename,
createLocalTerminal,
createSerialSession,
connectToHost,
closeSession,
closeWorkspace,
updateSessionStatus,
updateSessionFontSize,
clearSessionFontSizeOverride,
createWorkspaceWithHosts,
createWorkspaceFromTargets,
createWorkspaceFromSessions,
addSessionToWorkspace,
removeSessionFromWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
updateSplitSizes,
splitSession,
toggleWorkspaceViewMode,
setWorkspaceFocusedSession,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
orphanSessions,
// Broadcast mode
toggleBroadcast,
isBroadcastEnabled,
orderedTabs,
getOrderedWorkTabs,
reorderTabs,
// Log views
logViews,
openLogView,
closeLogView,
// Copy session
copySession,
createSessionFromCloneSource,
};
};