Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
1437 lines
54 KiB
TypeScript
1437 lines
54 KiB
TypeScript
import { MouseEvent,useCallback,useEffect,useMemo,useRef,useState } from 'react';
|
|
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceNode,WorkspaceViewMode } from '../../domain/models';
|
|
import { addLogView, getLogViewTabId, removeLogView, type LogView } from './logViewState';
|
|
import {
|
|
createHostTerminalSession,
|
|
createLocalTerminalSession,
|
|
createSerialTerminalSession,
|
|
createWorkspaceHostTerminalSession,
|
|
type LocalTerminalOptions,
|
|
} from './sessionFactories';
|
|
import { isScriptSnippet } from '../../domain/snippetScript.ts';
|
|
import {
|
|
appendPaneToWorkspaceRoot,
|
|
cloneWorkspaceTree,
|
|
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 {
|
|
publishSessionCodingCliProvider,
|
|
publishSessionDynamicTitle,
|
|
sessionPresentationStore,
|
|
} from './sessionPresentationStore';
|
|
import {
|
|
closeSessionsState,
|
|
detachSessionFromWorkspaceState,
|
|
replaceDissolvedWorkspaceTabOrder,
|
|
} from './sessionWorkspaceDetach';
|
|
import {
|
|
createCopiedTerminalSessionClone,
|
|
createSplitTerminalSessionClone,
|
|
} from './terminalConnectionReuse';
|
|
import { STORAGE_KEY_RESTORE_PREVIOUS_SESSION } from '../../infrastructure/config/storageKeys';
|
|
import {
|
|
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
|
localStorageAdapter,
|
|
} from '../../infrastructure/persistence/localStorageAdapter';
|
|
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
|
import { sessionRestoreStorage } from './sessionRestoreStorage';
|
|
import {
|
|
buildAndWriteSessionRestorePayload,
|
|
createInitialRestoredSessionState,
|
|
patchSessionRestoreActiveTabId,
|
|
shouldPersistSessionRestoreState,
|
|
updateRestoredSessionStatusState,
|
|
} from './sessionRestoreState';
|
|
import type { SessionRestorePayload } from '../../domain/sessionRestore';
|
|
import { resolveRestorePreviousSessionSetting } from './sessionRestoreSettings';
|
|
import type { CodingCliProviderId } from '../../domain/codingCliProviders';
|
|
import { normalizeCodingCliDynamicTitleForStorage } from '../../domain/codingCliTitleParse';
|
|
import {
|
|
DEFAULT_WORKSPACE_TITLE,
|
|
buildMergedWorkspaceTitle,
|
|
buildMergedWorkspaceTitleFromSessions,
|
|
getSessionConnectionLabel,
|
|
} from '../../domain/sessionTabTitle';
|
|
import { cleanupClosedTerminalSessions } from './aiStateSnapshots';
|
|
|
|
export function addWorkspaceIfMissing(
|
|
workspaces: Workspace[],
|
|
workspace: Workspace,
|
|
): Workspace[] {
|
|
return workspaces.some(ws => ws.id === workspace.id) ? workspaces : [...workspaces, workspace];
|
|
}
|
|
|
|
export function addTerminalSessionIfMissing(
|
|
sessions: TerminalSession[],
|
|
session: TerminalSession,
|
|
): TerminalSession[] {
|
|
return sessions.some(candidate => candidate.id === session.id) ? sessions : [...sessions, session];
|
|
}
|
|
|
|
export function insertWorkspacePaneIfMissing(
|
|
workspaces: Workspace[],
|
|
workspaceId: string,
|
|
sessionId: string,
|
|
hint: SplitHint,
|
|
): Workspace[] {
|
|
return workspaces.map(ws => {
|
|
if (ws.id !== workspaceId) return ws;
|
|
if (collectSessionIds(ws.root).includes(sessionId)) return ws;
|
|
return { ...ws, root: insertPaneIntoWorkspace(ws.root, sessionId, hint) };
|
|
});
|
|
}
|
|
|
|
export function appendWorkspaceRootPaneIfMissing(
|
|
workspaces: Workspace[],
|
|
workspaceId: string,
|
|
sessionId: string,
|
|
direction: SplitDirection = 'vertical',
|
|
): Workspace[] {
|
|
return workspaces.map(ws => {
|
|
if (ws.id !== workspaceId) return ws;
|
|
if (collectSessionIds(ws.root).includes(sessionId)) {
|
|
return ws.focusedSessionId === sessionId ? ws : { ...ws, focusedSessionId: sessionId };
|
|
}
|
|
return {
|
|
...ws,
|
|
root: appendPaneToWorkspaceRoot(ws.root, sessionId, direction),
|
|
focusedSessionId: sessionId,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function insertCopiedTabOrderIdOnce(
|
|
prevTabOrder: string[],
|
|
sourceTabId: string,
|
|
copiedTabId: string,
|
|
allTabIds: string[],
|
|
): string[] {
|
|
if (prevTabOrder.includes(copiedTabId)) return prevTabOrder;
|
|
|
|
const directIdx = prevTabOrder.indexOf(sourceTabId);
|
|
if (directIdx !== -1) {
|
|
const next = [...prevTabOrder];
|
|
next.splice(directIdx + 1, 0, copiedTabId);
|
|
return next;
|
|
}
|
|
|
|
// allTabIds should only ever list pre-existing tabs; defensively strip the
|
|
// copied id in case a caller's list includes it, so it isn't reconciled in
|
|
// twice (once via newIds, once via the explicit splice below).
|
|
const existingTabIds = allTabIds.filter(id => id !== copiedTabId);
|
|
const allTabIdSet = new Set(existingTabIds);
|
|
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
|
|
const orderedIdSet = new Set(orderedIds);
|
|
const newIds = existingTabIds.filter(id => !orderedIdSet.has(id));
|
|
const currentOrder = [...orderedIds, ...newIds];
|
|
const sourceIdx = currentOrder.indexOf(sourceTabId);
|
|
if (sourceIdx === -1) return [...prevTabOrder, copiedTabId];
|
|
const next = [...currentOrder];
|
|
next.splice(sourceIdx + 1, 0, copiedTabId);
|
|
return next;
|
|
}
|
|
|
|
export function buildCopiedWorkspace(
|
|
sourceWorkspace: Workspace,
|
|
prevSessions: TerminalSession[],
|
|
params: {
|
|
newWorkspaceId: string;
|
|
sessionIdMap: ReadonlyMap<string, string>;
|
|
localShellType?: TerminalSession['shellType'];
|
|
perPaneCwd?: Record<string, string | undefined>;
|
|
nodeIdMap?: ReadonlyMap<string, string>;
|
|
},
|
|
): { newSessions: TerminalSession[]; newWorkspace: Workspace } | null {
|
|
const sourceIds = collectSessionIds(sourceWorkspace.root);
|
|
const liveIds = sourceIds.filter(id => prevSessions.some(s => s.id === id));
|
|
if (liveIds.length === 0) return null;
|
|
|
|
// Prune panes whose source session is gone so the clone never references a
|
|
// missing session.
|
|
let prunedRoot = sourceWorkspace.root;
|
|
for (const deadId of sourceIds.filter(id => !liveIds.includes(id))) {
|
|
const next = pruneWorkspaceNode(prunedRoot, deadId);
|
|
if (next) prunedRoot = next;
|
|
}
|
|
|
|
// Precondition: sessionIdMap covers every id in collectSessionIds(root)
|
|
// (the sole caller, copyWorkspace, builds it that way), so every liveId has
|
|
// a mapping and the `as string` casts here and below are sound.
|
|
const liveIdMap = new Map<string, string>(
|
|
liveIds.map(id => [id, params.sessionIdMap.get(id) as string]),
|
|
);
|
|
|
|
const newSessions = liveIds.map(srcId => {
|
|
const src = prevSessions.find(s => s.id === srcId) as TerminalSession;
|
|
return createCopiedTerminalSessionClone(src, {
|
|
id: liveIdMap.get(srcId) as string,
|
|
workspaceId: params.newWorkspaceId,
|
|
localShellType: params.localShellType,
|
|
inheritedCwd: params.perPaneCwd?.[srcId],
|
|
});
|
|
});
|
|
|
|
const remap = (id?: string): string | undefined =>
|
|
id != null ? liveIdMap.get(id) : undefined;
|
|
|
|
const newWorkspace: Workspace = {
|
|
...sourceWorkspace,
|
|
id: params.newWorkspaceId,
|
|
root: cloneWorkspaceTree(prunedRoot, liveIdMap, params.nodeIdMap),
|
|
focusedSessionId: remap(sourceWorkspace.focusedSessionId)
|
|
?? liveIdMap.get(liveIds[0]),
|
|
focusSessionOrder: sourceWorkspace.focusSessionOrder
|
|
? sourceWorkspace.focusSessionOrder
|
|
.map(id => liveIdMap.get(id))
|
|
.filter((id): id is string => Boolean(id))
|
|
: undefined,
|
|
};
|
|
|
|
return { newSessions, newWorkspace };
|
|
}
|
|
|
|
export const useSessionState = ({
|
|
persistSessionRestore = true,
|
|
}: {
|
|
persistSessionRestore?: boolean;
|
|
} = {}) => {
|
|
const initialRestoreState = useMemo(() => createInitialRestoredSessionState({
|
|
restoreEnabled: persistSessionRestore && resolveRestorePreviousSessionSetting(
|
|
localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION),
|
|
),
|
|
payload: persistSessionRestore ? sessionRestoreStorage.read() : null,
|
|
}), [persistSessionRestore]);
|
|
const [sessions, setSessions] = useState<TerminalSession[]>(initialRestoreState.sessions);
|
|
const [workspaces, setWorkspaces] = useState<Workspace[]>(initialRestoreState.workspaces);
|
|
// 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[]>(initialRestoreState.tabOrder);
|
|
// Broadcast mode: stores workspace IDs that have broadcast enabled
|
|
const [broadcastWorkspaceIds, setBroadcastWorkspaceIds] = useState<Set<string>>(new Set());
|
|
// Global broadcast mode: enables broadcast for all orphan sessions (not in workspaces)
|
|
const [globalBroadcastEnabled, setGlobalBroadcastEnabled] = useState<boolean>(false);
|
|
// Log views: stores open log replay tabs
|
|
const [logViews, setLogViews] = useState<LogView[]>([]);
|
|
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
|
|
const sessionsRef = useRef(sessions);
|
|
const tabOrderRef = useRef(tabOrder);
|
|
const scheduleSessionRestorePersistRef = useRef<() => void>(() => {});
|
|
const sessionRestoreCwdByIdRef = useRef(
|
|
new Map(
|
|
initialRestoreState.sessions
|
|
.filter((session) => Boolean(session.lastCwd))
|
|
.map((session) => [session.id, session.lastCwd as string]),
|
|
),
|
|
);
|
|
const hasSeenRestorableSessionRestoreStateRef = useRef(
|
|
persistSessionRestore && shouldPersistSessionRestoreState(
|
|
initialRestoreState.sessions,
|
|
initialRestoreState.workspaces,
|
|
initialRestoreState.tabOrder,
|
|
),
|
|
);
|
|
sessionsRef.current = sessions;
|
|
tabOrderRef.current = tabOrder;
|
|
if (persistSessionRestore && shouldPersistSessionRestoreState(sessions, workspaces, tabOrder)) {
|
|
hasSeenRestorableSessionRestoreStateRef.current = true;
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (initialRestoreState.activeTabId !== 'vault') {
|
|
activeTabStore.setActiveTabId(initialRestoreState.activeTabId);
|
|
}
|
|
}, [initialRestoreState.activeTabId]);
|
|
|
|
// Keep generated merged-workspace titles (e.g. "01/02" after dragging tab
|
|
// "02" onto "01") synchronized with the workspace's current membership and
|
|
// each pane's connection label. `autoTitle: false` makes
|
|
// resolveWorkspaceTabLabel treat the composed title as a user-chosen name,
|
|
// so without this pass a pane rename or a pane being added/removed would
|
|
// leave the label stale. Runs off session changes and only touches
|
|
// workspaces still flagged `generatedTitle` (a user rename clears it).
|
|
useEffect(() => {
|
|
setWorkspaces(prev => {
|
|
let changed = false;
|
|
const next = prev.map(ws => {
|
|
if (!ws.generatedTitle) return ws;
|
|
// Order members by the workspace's pane tree (base/join or split
|
|
// layout), not by global session creation order — merging a newer tab
|
|
// onto an older one must keep the merged title as created (e.g.
|
|
// "02/01"), while `sessions.filter` would rewrite it to "01/02".
|
|
const rootIds = collectSessionIds(ws.root);
|
|
const members = [
|
|
...rootIds
|
|
.map(id => sessions.find(s => s.id === id))
|
|
.filter((s): s is TerminalSession => Boolean(s)),
|
|
// Safety net for transient membership: any session assigned to this
|
|
// workspace whose pane has not landed in `root` yet keeps its
|
|
// previous (creation-order) position after the pane-tree members.
|
|
...sessions.filter(s => s.workspaceId === ws.id && !rootIds.includes(s.id)),
|
|
];
|
|
const recomputed = buildMergedWorkspaceTitleFromSessions(members) ?? DEFAULT_WORKSPACE_TITLE;
|
|
if (recomputed === ws.title) return ws;
|
|
changed = true;
|
|
return { ...ws, title: recomputed };
|
|
});
|
|
return changed ? next : prev;
|
|
});
|
|
}, [sessions]);
|
|
|
|
useEffect(() => {
|
|
const handleRestorePreviousSessionChanged = (key?: string) => {
|
|
if (key !== STORAGE_KEY_RESTORE_PREVIOUS_SESSION) return;
|
|
setRestorePreviousSessionRevision((revision) => revision + 1);
|
|
};
|
|
const handleLocalStorageAdapterChanged = (event: Event) => {
|
|
const detail = (event as CustomEvent<{ key?: string }>).detail;
|
|
handleRestorePreviousSessionChanged(detail?.key);
|
|
};
|
|
|
|
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
|
|
const unsubscribeSettingsSync = netcattyBridge.get()?.onSettingsChanged?.((payload) => {
|
|
handleRestorePreviousSessionChanged(payload?.key);
|
|
});
|
|
return () => {
|
|
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
|
|
unsubscribeSettingsSync?.();
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!persistSessionRestore) return;
|
|
|
|
const restoreEnabled = resolveRestorePreviousSessionSetting(
|
|
localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION),
|
|
);
|
|
if (!restoreEnabled) {
|
|
scheduleSessionRestorePersistRef.current = () => {};
|
|
sessionRestoreStorage.clear();
|
|
hasSeenRestorableSessionRestoreStateRef.current = false;
|
|
return;
|
|
}
|
|
|
|
let timeout: number | undefined;
|
|
let activeTabPatchTimeout: number | undefined;
|
|
// Last full restore payload written this session. Tab switches patch only
|
|
// activeTabId against this cache instead of re-serializing every session.
|
|
let lastFullPayload: SessionRestorePayload | null = null;
|
|
|
|
const persistNow = () => {
|
|
const sessionsForRestore = sessionsRef.current.map((session) => {
|
|
const cwd = sessionRestoreCwdByIdRef.current.get(session.id);
|
|
if (cwd) {
|
|
return session.lastCwd === cwd ? session : { ...session, lastCwd: cwd };
|
|
}
|
|
if (session.lastCwd === undefined) return session;
|
|
const { lastCwd: _lastCwd, ...rest } = session;
|
|
return rest;
|
|
});
|
|
const hasRestorableState = shouldPersistSessionRestoreState(
|
|
sessionsForRestore,
|
|
workspacesRef.current,
|
|
tabOrderRef.current,
|
|
);
|
|
const clearOnEmpty = hasSeenRestorableSessionRestoreStateRef.current && !hasRestorableState;
|
|
const wrote = buildAndWriteSessionRestorePayload({
|
|
restoreEnabled: resolveRestorePreviousSessionSetting(
|
|
localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION),
|
|
),
|
|
clearOnEmpty,
|
|
sessions: sessionsForRestore,
|
|
workspaces: workspacesRef.current,
|
|
tabOrder: tabOrderRef.current,
|
|
activeTabId: activeTabStore.getActiveTabId(),
|
|
storage: {
|
|
write: (payload) => {
|
|
lastFullPayload = payload;
|
|
return sessionRestoreStorage.write(payload);
|
|
},
|
|
clear: () => {
|
|
lastFullPayload = null;
|
|
sessionRestoreStorage.clear();
|
|
},
|
|
},
|
|
});
|
|
if (!wrote && !hasRestorableState) {
|
|
lastFullPayload = null;
|
|
}
|
|
};
|
|
|
|
const schedulePersist = () => {
|
|
if (timeout !== undefined) {
|
|
window.clearTimeout(timeout);
|
|
}
|
|
timeout = window.setTimeout(() => {
|
|
timeout = undefined;
|
|
persistNow();
|
|
}, 250);
|
|
};
|
|
|
|
const scheduleActiveTabPatch = () => {
|
|
// Coalesce rapid tab clicks. Only patch when we hold a trusted full payload
|
|
// written by persistNow in this effect. Never storage.read() as a base —
|
|
// disk can still have pre-connect sessions while live already added a host
|
|
// and activated its tab (effect recreated, lastFullPayload reset to null).
|
|
if (activeTabPatchTimeout !== undefined) {
|
|
window.clearTimeout(activeTabPatchTimeout);
|
|
}
|
|
activeTabPatchTimeout = window.setTimeout(() => {
|
|
activeTabPatchTimeout = undefined;
|
|
const activeTabId = activeTabStore.getActiveTabId();
|
|
if (!lastFullPayload) {
|
|
// Cache empty after effect recreate or before first full write — rebuild
|
|
// from live sessions/workspaces (debounced with other structural persists).
|
|
schedulePersist();
|
|
return;
|
|
}
|
|
const result = patchSessionRestoreActiveTabId({
|
|
activeTabId,
|
|
cachedPayload: lastFullPayload,
|
|
storage: {
|
|
write: (payload) => {
|
|
lastFullPayload = payload;
|
|
return sessionRestoreStorage.write(payload);
|
|
},
|
|
},
|
|
});
|
|
if (result.status === "missing") {
|
|
schedulePersist();
|
|
}
|
|
}, 100);
|
|
};
|
|
|
|
schedulePersist();
|
|
scheduleSessionRestorePersistRef.current = schedulePersist;
|
|
const unsubscribeActiveTab = activeTabStore.subscribeSync(scheduleActiveTabPatch);
|
|
|
|
const handlePageHide = () => {
|
|
if (timeout !== undefined) {
|
|
window.clearTimeout(timeout);
|
|
timeout = undefined;
|
|
}
|
|
if (activeTabPatchTimeout !== undefined) {
|
|
window.clearTimeout(activeTabPatchTimeout);
|
|
activeTabPatchTimeout = undefined;
|
|
}
|
|
persistNow();
|
|
};
|
|
|
|
window.addEventListener("pagehide", handlePageHide);
|
|
window.addEventListener("beforeunload", handlePageHide);
|
|
|
|
return () => {
|
|
scheduleSessionRestorePersistRef.current = () => {};
|
|
if (timeout !== undefined) {
|
|
window.clearTimeout(timeout);
|
|
}
|
|
if (activeTabPatchTimeout !== undefined) {
|
|
window.clearTimeout(activeTabPatchTimeout);
|
|
}
|
|
unsubscribeActiveTab();
|
|
window.removeEventListener("pagehide", handlePageHide);
|
|
window.removeEventListener("beforeunload", handlePageHide);
|
|
};
|
|
}, [sessions, workspaces, tabOrder, restorePreviousSessionRevision, persistSessionRestore]);
|
|
|
|
const updateSessionRestoreCwd = useCallback((sessionId: string, cwd: string | null) => {
|
|
const nextCwd = cwd && cwd.trim().length > 0 ? cwd : null;
|
|
const currentCwd = sessionRestoreCwdByIdRef.current.get(sessionId) ?? null;
|
|
if (currentCwd === nextCwd) return;
|
|
if (nextCwd) {
|
|
sessionRestoreCwdByIdRef.current.set(sessionId, nextCwd);
|
|
// Once a live cwd is tracked, the one-shot inherited-cwd seed set by a
|
|
// clone/split has served its purpose — drop it so a later remount +
|
|
// fresh reconnect doesn't re-inject the stale `cd`. The functional
|
|
// update returns `prev` unchanged (no re-render) unless the field is set.
|
|
setSessions((prev) => {
|
|
const target = prev.find((candidate) => candidate.id === sessionId);
|
|
if (!target || target.pendingInitialCwd === undefined) return prev;
|
|
return prev.map((candidate) => {
|
|
if (candidate.id !== sessionId) return candidate;
|
|
const { pendingInitialCwd: _spent, ...rest } = candidate;
|
|
return rest;
|
|
});
|
|
});
|
|
} else {
|
|
sessionRestoreCwdByIdRef.current.delete(sessionId);
|
|
}
|
|
scheduleSessionRestorePersistRef.current();
|
|
}, []);
|
|
|
|
// The live tracked cwd (OSC 7) is kept in a ref, not on the session object,
|
|
// so expose a reader for callers (clone/split cwd inheritance) that need the
|
|
// current directory of a running session without forcing a re-render.
|
|
const getSessionRestoreCwd = useCallback(
|
|
(sessionId: string): string | undefined => sessionRestoreCwdByIdRef.current.get(sessionId) ?? undefined,
|
|
[],
|
|
);
|
|
|
|
const updateSessionDynamicTitle = useCallback((sessionId: string, title: string | null) => {
|
|
const normalizedTitle = title ? normalizeCodingCliDynamicTitleForStorage(title) : '';
|
|
const nextTitle = normalizedTitle.length > 0 ? normalizedTitle : null;
|
|
// Presentation-only: write the external store so TopTabs / focus sidebar /
|
|
// pane chrome refresh via applySessionPresentation without setSessions
|
|
// thrashing appTerminalDomain / TerminalLayer parents.
|
|
publishSessionDynamicTitle(sessionId, nextTitle);
|
|
}, []);
|
|
|
|
const updateSessionCodingCliProvider = useCallback((
|
|
sessionId: string,
|
|
providerId: CodingCliProviderId | null,
|
|
) => {
|
|
// Presentation-only: store-driven icon chrome. Consumers must overlay via
|
|
// applySessionPresentation / usePresentedSession (TopTabs, focus sidebar, panes).
|
|
publishSessionCodingCliProvider(sessionId, providerId);
|
|
}, []);
|
|
|
|
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, options?: { hidden?: boolean }) => {
|
|
const hidden = options?.hidden === true;
|
|
const newSession = createHostTerminalSession(crypto.randomUUID(), host);
|
|
const sessionToAdd = hidden ? { ...newSession, hiddenFromTabs: true } : newSession;
|
|
setSessions(prev => [...prev, sessionToAdd]);
|
|
if (!hidden) setActiveTabId(newSession.id);
|
|
return newSession.id;
|
|
}, [setActiveTabId]);
|
|
|
|
const updateSessionStatus = useCallback((sessionId: string, status: TerminalSession['status']) => {
|
|
setSessions(prev => updateRestoredSessionStatusState(prev, sessionId, status));
|
|
}, []);
|
|
|
|
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) => {
|
|
const closedIds = sessionsRef.current
|
|
.filter(session => session.workspaceId === workspaceId)
|
|
.map(session => session.id);
|
|
cleanupClosedTerminalSessions(closedIds);
|
|
for (const sessionId of closedIds) {
|
|
sessionPresentationStore.clearSession(sessionId);
|
|
}
|
|
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 closeSessions = useCallback((sessionIds: string[]) => {
|
|
cleanupClosedTerminalSessions(sessionIds);
|
|
for (const sessionId of sessionIds) {
|
|
sessionPresentationStore.clearSession(sessionId);
|
|
}
|
|
const result = closeSessionsState({
|
|
sessions: sessionsRef.current,
|
|
workspaces: workspacesRef.current,
|
|
sessionIds,
|
|
currentActiveTabId: activeTabStore.getActiveTabId(),
|
|
preferredTabId: activeTabStore.getPreviousActiveTabId(),
|
|
tabOrder: tabOrderRef.current,
|
|
});
|
|
|
|
setWorkspaces(result.workspaces);
|
|
setSessions(result.sessions);
|
|
setTabOrder(result.tabOrder);
|
|
if (result.activeTabId) {
|
|
// Closing must not rewrite previous-tab history to the closed tab.
|
|
activeTabStore.setActiveTabId(result.activeTabId, { recordPrevious: false });
|
|
}
|
|
}, []);
|
|
|
|
const closeSession = useCallback((sessionId: string, e?: MouseEvent) => {
|
|
e?.stopPropagation();
|
|
closeSessions([sessionId]);
|
|
}, [closeSessions]);
|
|
|
|
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((workspaceId?: string, name?: string) => {
|
|
// A user rename takes ownership of the title: clear `generatedTitle` so a
|
|
// previously synthesized merged title ("01/02") stops being auto-updated.
|
|
const applyRename = (w: Workspace, trimmed: string): Workspace => (
|
|
{ ...w, title: trimmed, autoTitle: false, generatedTitle: false }
|
|
);
|
|
if (workspaceId !== undefined && name !== undefined) {
|
|
const trimmed = name.trim();
|
|
if (!trimmed) return;
|
|
setWorkspaces(prev => prev.map(w => (
|
|
w.id === workspaceId ? applyRename(w, trimmed) : w
|
|
)));
|
|
return;
|
|
}
|
|
|
|
setWorkspaceRenameValue(prevValue => {
|
|
const trimmed = prevValue.trim();
|
|
if (!trimmed) return prevValue;
|
|
|
|
setWorkspaceRenameTarget(prevTarget => {
|
|
if (!prevTarget) return prevTarget;
|
|
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? applyRename(w, trimmed) : w));
|
|
return null;
|
|
});
|
|
|
|
return '';
|
|
});
|
|
}, []);
|
|
|
|
const resetWorkspaceRename = useCallback(() => {
|
|
setWorkspaceRenameTarget(null);
|
|
setWorkspaceRenameValue('');
|
|
}, []);
|
|
|
|
const createWorkspaceWithHosts = useCallback((name: string, hosts: Host[]) => {
|
|
if (hosts.length === 0) return;
|
|
|
|
const sessionSeeds = hosts.map(host => ({
|
|
id: crypto.randomUUID(),
|
|
host,
|
|
}));
|
|
|
|
const sessionIds = sessionSeeds.map(s => s.id);
|
|
|
|
// Create workspace
|
|
const workspace = createWorkspaceFromSessionIds(sessionIds, {
|
|
title: name,
|
|
autoTitle: false,
|
|
viewMode: 'split',
|
|
});
|
|
|
|
// Assign workspaceId to sessions
|
|
const sessionsWithWorkspace = sessionSeeds.map(({ id, host }) => (
|
|
createWorkspaceHostTerminalSession(id, host, 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): string | null => {
|
|
if (targets.length === 0) return null;
|
|
// A caller-provided name is an explicit title; without one the workspace is
|
|
// auto-titled and the tab derives a host-based label.
|
|
const explicitName = name?.trim();
|
|
const workspaceTitle = explicitName || DEFAULT_WORKSPACE_TITLE;
|
|
|
|
const newSessions: TerminalSession[] = targets.map((target) => {
|
|
if (target.kind === 'local') {
|
|
const sessionId = crypto.randomUUID();
|
|
return createLocalTerminalSession(sessionId, {
|
|
shellType: target.shellType,
|
|
shell: target.shell,
|
|
shellArgs: target.shellArgs,
|
|
shellName: target.shellName,
|
|
shellIcon: target.shellIcon,
|
|
});
|
|
}
|
|
const host = target.host;
|
|
return createHostTerminalSession(crypto.randomUUID(), host);
|
|
});
|
|
|
|
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: workspaceTitle,
|
|
autoTitle: explicitName ? false : undefined,
|
|
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;
|
|
// Name the merged workspace after both tabs' connection labels (e.g.
|
|
// "01/02") so the window title and the rename dialog show the merged
|
|
// identity instead of the generic "Workspace". Falls back to the default
|
|
// auto title when neither tab has a label. The explicit `autoTitle: false`
|
|
// makes tab labels use this composed name too; `generatedTitle: true`
|
|
// marks it as synthesized so it keeps tracking membership/renames (see the
|
|
// generated-title sync effect below) instead of going stale.
|
|
// Compose the title from the created pane-tree order (not base/join
|
|
// order): for left/top merges `createWorkspaceEntity` places the joining
|
|
// pane before the base pane in `root`, and the generated-title sync
|
|
// effect above recomputes from `root` order — building the initial title
|
|
// in tree order keeps it stable instead of renaming itself right after
|
|
// creation.
|
|
const created = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
|
|
const treeLabels = collectSessionIds(created.root).map((id) =>
|
|
getSessionConnectionLabel(
|
|
sessionsRef.current.find(s => s.id === id) ?? { customName: '', hostLabel: '' },
|
|
),
|
|
);
|
|
const mergedTitle = buildMergedWorkspaceTitle(treeLabels[0] ?? '', treeLabels[1] ?? '');
|
|
const newWorkspace = mergedTitle
|
|
? { ...created, title: mergedTitle, autoTitle: false, generatedTitle: true }
|
|
: created;
|
|
|
|
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;
|
|
|
|
setWorkspaces((prev) => addWorkspaceIfMissing(prev, newWorkspace));
|
|
// Collapse the two session tab slots into the workspace tab so later
|
|
// detach/close can replace `ws-*` with session ids again.
|
|
setTabOrder((prevTabOrder) => {
|
|
const withoutSessions = prevTabOrder.filter(
|
|
(id) => id !== baseSessionId && id !== joiningSessionId,
|
|
);
|
|
if (withoutSessions.includes(newWorkspace.id)) return withoutSessions;
|
|
const indexes = [baseSessionId, joiningSessionId]
|
|
.map((id) => prevTabOrder.indexOf(id))
|
|
.filter((index) => index >= 0);
|
|
const insertAt = indexes.length > 0 ? Math.min(...indexes) : withoutSessions.length;
|
|
const next = [...withoutSessions];
|
|
next.splice(Math.min(insertAt, next.length), 0, newWorkspace.id);
|
|
return next;
|
|
});
|
|
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 insertWorkspacePaneIfMissing(prevWorkspaces, workspaceId, 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 => {
|
|
// 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 = createWorkspaceHostTerminalSession(newSessionId, host, 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 => addTerminalSessionIfMissing(s, newSession));
|
|
setActiveTabId(workspaceId);
|
|
return appendWorkspaceRootPaneIfMissing(prev, workspaceId, newSessionId, direction);
|
|
});
|
|
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 newSession: TerminalSession = {
|
|
...createLocalTerminalSession(newSessionId, {
|
|
shellType: options?.shellType,
|
|
shell: options?.shell,
|
|
shellArgs: options?.shellArgs,
|
|
shellName: options?.shellName,
|
|
shellIcon: options?.shellIcon,
|
|
}),
|
|
workspaceId,
|
|
};
|
|
|
|
setWorkspaces(prev => {
|
|
const target = prev.find(w => w.id === workspaceId);
|
|
if (!target) return prev;
|
|
setSessions(s => addTerminalSessionIfMissing(s, newSession));
|
|
setActiveTabId(workspaceId);
|
|
return appendWorkspaceRootPaneIfMissing(prev, workspaceId, newSessionId, direction);
|
|
});
|
|
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'];
|
|
inheritedCwd?: string;
|
|
},
|
|
) => {
|
|
const newSessionId = crypto.randomUUID();
|
|
const standaloneHint: SplitHint = {
|
|
direction,
|
|
position: direction === 'horizontal' ? 'bottom' : 'right',
|
|
};
|
|
const standaloneWorkspace = createWorkspaceEntity(sessionId, newSessionId, standaloneHint);
|
|
|
|
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: newSessionId,
|
|
localShellType: options?.localShellType,
|
|
inheritedCwd: options?.inheritedCwd,
|
|
workspaceId: session.workspaceId,
|
|
});
|
|
|
|
// Add pane to existing workspace
|
|
const hint: SplitHint = {
|
|
direction,
|
|
position: direction === 'horizontal' ? 'bottom' : 'right',
|
|
targetSessionId: sessionId,
|
|
};
|
|
|
|
setWorkspaces(prevWorkspaces => {
|
|
return insertWorkspacePaneIfMissing(prevWorkspaces, session.workspaceId, newSession.id, hint);
|
|
});
|
|
|
|
return addTerminalSessionIfMissing(prevSessions, newSession);
|
|
}
|
|
|
|
// Session is standalone - create a new workspace
|
|
const newSession = createSplitTerminalSessionClone(session, {
|
|
id: newSessionId,
|
|
localShellType: options?.localShellType,
|
|
inheritedCwd: options?.inheritedCwd,
|
|
});
|
|
|
|
setWorkspaces(prev => addWorkspaceIfMissing(prev, standaloneWorkspace));
|
|
setActiveTabId(standaloneWorkspace.id);
|
|
|
|
return prevSessions.map(s => {
|
|
if (s.id === sessionId) {
|
|
return { ...s, workspaceId: standaloneWorkspace.id };
|
|
}
|
|
return s;
|
|
}).concat({ ...newSession, workspaceId: standaloneWorkspace.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,
|
|
autoTitle: false,
|
|
viewMode: 'focus',
|
|
snippetId: snippet.id,
|
|
});
|
|
|
|
// Update sessions with workspaceId
|
|
const sessionsWithWorkspace = newSessions.map(s => ({
|
|
...s,
|
|
workspaceId: workspace.id,
|
|
...(isScriptSnippet(snippet)
|
|
? { pendingScriptId: snippet.id, pendingScript: snippet }
|
|
: {
|
|
startupCommand: resolvedCommand,
|
|
noAutoRun: snippet.noAutoRun,
|
|
multiLineRunMode: snippet.multiLineRunMode ?? 'paste',
|
|
}),
|
|
}));
|
|
|
|
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
|
|
setWorkspaces(prev => [...prev, workspace]);
|
|
setActiveTabId(workspace.id);
|
|
}, [setActiveTabId]);
|
|
|
|
const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId && !s.hiddenFromTabs), [sessions]);
|
|
const canUseGlobalBroadcast = orphanSessions.length >= 2;
|
|
|
|
// Losing the group must disarm broadcast before another tab can join it.
|
|
if (!canUseGlobalBroadcast && globalBroadcastEnabled) {
|
|
setGlobalBroadcastEnabled(false);
|
|
}
|
|
|
|
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'];
|
|
inheritedCwd?: string;
|
|
/** When false, open a brand-new connection instead of reusing the source's SSH channel. */
|
|
reuseConnection?: boolean;
|
|
}) => {
|
|
// 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,
|
|
inheritedCwd: options?.inheritedCwd,
|
|
reuseConnection: options?.reuseConnection,
|
|
});
|
|
|
|
// 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 => {
|
|
const allTabIds = [
|
|
...orphanSessions.map(s => s.id),
|
|
...workspaces.map(w => w.id),
|
|
...logViews.map(lv => lv.id),
|
|
];
|
|
return insertCopiedTabOrderIdOnce(prevTabOrder, sessionId, newSessionId, allTabIds);
|
|
});
|
|
|
|
return [...prevSessions, newSession];
|
|
});
|
|
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
|
|
|
|
// Copy a whole workspace (split/focus tab): clone every pane's session and
|
|
// reproduce the layout tree in a new tab.
|
|
const copyWorkspace = useCallback((workspaceId: string, options?: {
|
|
localShellType?: TerminalSession['shellType'];
|
|
perPaneCwd?: Record<string, string | undefined>;
|
|
}) => {
|
|
const sourceWorkspace = workspaces.find(w => w.id === workspaceId);
|
|
if (!sourceWorkspace) return;
|
|
|
|
// Pre-mint the new workspace id and the old->new session-id map OUTSIDE the
|
|
// updater so StrictMode's double-invocation does not mint two sets of ids.
|
|
const newWorkspaceId = `ws-${crypto.randomUUID()}`;
|
|
const sessionIdMap = new Map<string, string>(
|
|
collectSessionIds(sourceWorkspace.root).map(id => [id, crypto.randomUUID()]),
|
|
);
|
|
const nodeIdMap = new Map<string, string>();
|
|
const preMintNodeIds = (node: WorkspaceNode) => {
|
|
nodeIdMap.set(node.id, crypto.randomUUID());
|
|
if (node.type === 'split') node.children.forEach(preMintNodeIds);
|
|
};
|
|
preMintNodeIds(sourceWorkspace.root);
|
|
const newSessionIds = new Set(sessionIdMap.values());
|
|
|
|
setSessions(prevSessions => {
|
|
// The cwd capture above is async. Avoid resurrecting a workspace that was
|
|
// closed (or structurally changed) while those probes were pending.
|
|
if (workspacesRef.current.find(w => w.id === workspaceId) !== sourceWorkspace) {
|
|
return prevSessions;
|
|
}
|
|
if (prevSessions.some(session => newSessionIds.has(session.id))) return prevSessions;
|
|
const built = buildCopiedWorkspace(sourceWorkspace, prevSessions, {
|
|
newWorkspaceId,
|
|
sessionIdMap,
|
|
localShellType: options?.localShellType,
|
|
perPaneCwd: options?.perPaneCwd,
|
|
nodeIdMap,
|
|
});
|
|
// Every source pane was closed between click and update — skip cleanly.
|
|
if (!built) return prevSessions;
|
|
|
|
// Nested idempotent setStates (mirrors copySession's pattern).
|
|
setWorkspaces(prev => addWorkspaceIfMissing(prev, built.newWorkspace));
|
|
setActiveTabId(newWorkspaceId);
|
|
setTabOrder(prevTabOrder => {
|
|
const allTabIds = [
|
|
...orphanSessions.map(s => s.id),
|
|
...workspaces.map(w => w.id),
|
|
...logViews.map(lv => lv.id),
|
|
];
|
|
return insertCopiedTabOrderIdOnce(prevTabOrder, workspaceId, newWorkspaceId, allTabIds);
|
|
});
|
|
|
|
return [...prevSessions, ...built.newSessions];
|
|
});
|
|
}, [workspaces, orphanSessions, 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]);
|
|
|
|
// Toggle global broadcast mode for orphan sessions
|
|
const toggleGlobalBroadcast = useCallback(() => {
|
|
setGlobalBroadcastEnabled(prev => !prev);
|
|
}, []);
|
|
|
|
// Global broadcast enabled state - direct boolean value
|
|
const isGlobalBroadcastEnabled = globalBroadcastEnabled;
|
|
|
|
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[];
|
|
},
|
|
) => {
|
|
// Detach from latest refs so continuous-render / memoized panes cannot
|
|
// act on a stale workspace snapshot and drop the terminal instead of
|
|
// restoring it as a standalone tab.
|
|
const result = detachSessionFromWorkspaceState({
|
|
sessions: sessionsRef.current,
|
|
workspaces: workspacesRef.current,
|
|
sessionId,
|
|
});
|
|
if (!result.changed) return;
|
|
|
|
setWorkspaces(result.workspaces);
|
|
setSessions(result.sessions);
|
|
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);
|
|
}, [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,
|
|
closeSessions,
|
|
closeWorkspace,
|
|
updateSessionStatus,
|
|
updateSessionFontSize,
|
|
clearSessionFontSizeOverride,
|
|
createWorkspaceWithHosts,
|
|
createWorkspaceFromTargets,
|
|
createWorkspaceFromSessions,
|
|
addSessionToWorkspace,
|
|
removeSessionFromWorkspace,
|
|
appendHostToWorkspace,
|
|
appendLocalTerminalToWorkspace,
|
|
updateSplitSizes,
|
|
splitSession,
|
|
toggleWorkspaceViewMode,
|
|
setWorkspaceFocusedSession,
|
|
reorderWorkspaceSessions,
|
|
moveFocusInWorkspace,
|
|
runSnippet,
|
|
orphanSessions,
|
|
canUseGlobalBroadcast,
|
|
// Broadcast mode
|
|
toggleBroadcast,
|
|
isBroadcastEnabled,
|
|
toggleGlobalBroadcast,
|
|
isGlobalBroadcastEnabled,
|
|
orderedTabs,
|
|
getOrderedWorkTabs,
|
|
reorderTabs,
|
|
// Log views
|
|
logViews,
|
|
openLogView,
|
|
closeLogView,
|
|
// Copy session
|
|
copySession,
|
|
copyWorkspace,
|
|
createSessionFromCloneSource,
|
|
updateSessionRestoreCwd,
|
|
getSessionRestoreCwd,
|
|
updateSessionDynamicTitle,
|
|
updateSessionCodingCliProvider,
|
|
};
|
|
};
|