Store per-session font size in workspace splits so Ctrl+wheel zoom no longer changes sibling panes or reverts on blur when terminalSettings re-sync runs. Co-authored-by: Cursor <cursoragent@cursor.com>
990 lines
35 KiB
TypeScript
990 lines
35 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 } from '../app/workTabSurface';
|
|
import { activeTabStore } from './activeTabStore';
|
|
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 => {
|
|
let removedWorkspaceId: string | null = null;
|
|
let nextWorkspaces = prevWorkspaces;
|
|
let dissolvedWorkspaceId: string | null = null;
|
|
let lastRemainingSessionId: string | null = null;
|
|
|
|
if (wsId) {
|
|
nextWorkspaces = prevWorkspaces
|
|
.map(ws => {
|
|
if (ws.id !== wsId) return ws;
|
|
const pruned = pruneWorkspaceNode(ws.root, sessionId);
|
|
if (!pruned) {
|
|
removedWorkspaceId = ws.id;
|
|
return null;
|
|
}
|
|
|
|
// Check if only 1 session remains - dissolve workspace
|
|
const remainingSessionIds = collectSessionIds(pruned);
|
|
if (remainingSessionIds.length === 1) {
|
|
dissolvedWorkspaceId = ws.id;
|
|
lastRemainingSessionId = remainingSessionIds[0];
|
|
return null;
|
|
}
|
|
|
|
return { ...ws, root: pruned };
|
|
})
|
|
.filter((ws): ws is Workspace => Boolean(ws));
|
|
}
|
|
|
|
const 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 && 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.hostLabel);
|
|
}
|
|
return prevSessions;
|
|
});
|
|
}, []);
|
|
|
|
const submitSessionRename = useCallback(() => {
|
|
setSessionRenameValue(prevValue => {
|
|
const name = prevValue.trim();
|
|
if (!name) return prevValue;
|
|
|
|
setSessionRenameTarget(prevTarget => {
|
|
if (!prevTarget) return prevTarget;
|
|
setSessions(prev => prev.map(s => s.id === prevTarget.id ? { ...s, hostLabel: name } : 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 reorderTabs = useCallback((
|
|
draggedId: string,
|
|
targetId: string,
|
|
position: 'before' | 'after' = 'before',
|
|
additionalTabIds: readonly string[] = [],
|
|
) => {
|
|
if (draggedId === targetId) return;
|
|
|
|
setTabOrder(prevTabOrder => {
|
|
const allTabIds = [...baseWorkTabIds, ...additionalTabIds];
|
|
const allTabIdSet = new Set(allTabIds);
|
|
|
|
// Build current effective order: existing order + new tabs at end
|
|
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
|
|
const orderedIdSet = new Set(orderedIds);
|
|
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
|
|
const currentOrder = [...orderedIds, ...newIds];
|
|
|
|
const draggedIndex = currentOrder.indexOf(draggedId);
|
|
const targetIndex = currentOrder.indexOf(targetId);
|
|
|
|
if (draggedIndex === -1 || targetIndex === -1) return prevTabOrder;
|
|
|
|
// Remove dragged item first
|
|
currentOrder.splice(draggedIndex, 1);
|
|
|
|
// Calculate new target index (adjusted after removal)
|
|
let newTargetIndex = targetIndex;
|
|
if (draggedIndex < targetIndex) {
|
|
newTargetIndex -= 1;
|
|
}
|
|
|
|
// Insert at the correct position
|
|
if (position === 'after') {
|
|
newTargetIndex += 1;
|
|
}
|
|
|
|
currentOrder.splice(newTargetIndex, 0, draggedId);
|
|
|
|
return currentOrder;
|
|
});
|
|
}, [baseWorkTabIds]);
|
|
|
|
return {
|
|
sessions,
|
|
workspaces,
|
|
// activeTabId removed - components should subscribe via useActiveTabId() from activeTabStore
|
|
setActiveTabId,
|
|
draggingSessionId,
|
|
setDraggingSessionId,
|
|
sessionRenameTarget,
|
|
sessionRenameValue,
|
|
setSessionRenameValue,
|
|
startSessionRename,
|
|
submitSessionRename,
|
|
resetSessionRename,
|
|
workspaceRenameTarget,
|
|
workspaceRenameValue,
|
|
setWorkspaceRenameValue,
|
|
startWorkspaceRename,
|
|
submitWorkspaceRename,
|
|
resetWorkspaceRename,
|
|
createLocalTerminal,
|
|
createSerialSession,
|
|
connectToHost,
|
|
closeSession,
|
|
closeWorkspace,
|
|
updateSessionStatus,
|
|
updateSessionFontSize,
|
|
clearSessionFontSizeOverride,
|
|
createWorkspaceWithHosts,
|
|
createWorkspaceFromTargets,
|
|
createWorkspaceFromSessions,
|
|
addSessionToWorkspace,
|
|
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,
|
|
};
|
|
};
|