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
204 lines
5.9 KiB
TypeScript
204 lines
5.9 KiB
TypeScript
import { useSyncExternalStore } from 'react';
|
|
|
|
import type { TerminalSession, Workspace } from '../../domain/models';
|
|
import type { LogView } from './logViewState';
|
|
import type { useSessionState } from './useSessionState';
|
|
|
|
type SessionState = ReturnType<typeof useSessionState>;
|
|
|
|
type Listener = () => void;
|
|
|
|
/**
|
|
* Session/workspace catalog for shell surfaces. `activeTabId` is intentionally
|
|
* absent — it lives in `activeTabStore` so tab switches stay off this path.
|
|
*/
|
|
export type SessionSnapshot = {
|
|
sessions: readonly TerminalSession[];
|
|
/** Sessions with no workspace and not hidden from tabs; TopTabs renders these. */
|
|
orphanSessions: readonly TerminalSession[];
|
|
workspaces: readonly Workspace[];
|
|
logViews: readonly LogView[];
|
|
draggingSessionId: string | null;
|
|
sessionRenameTarget: TerminalSession | null;
|
|
workspaceRenameTarget: Workspace | null;
|
|
};
|
|
|
|
/**
|
|
* Session mutators the shell needs. Derived from the hook return so the store
|
|
* contract cannot drift from `useSessionState`.
|
|
*/
|
|
export type SessionSnapshotActions = Pick<
|
|
SessionState,
|
|
| 'setActiveTabId'
|
|
| 'closeSession'
|
|
| 'closeSessions'
|
|
| 'closeWorkspace'
|
|
| 'openLogView'
|
|
| 'closeLogView'
|
|
| 'setDraggingSessionId'
|
|
| 'startSessionRename'
|
|
| 'renameSessionInline'
|
|
| 'submitSessionRename'
|
|
| 'resetSessionRename'
|
|
| 'startWorkspaceRename'
|
|
| 'submitWorkspaceRename'
|
|
| 'resetWorkspaceRename'
|
|
| 'removeSessionFromWorkspace'
|
|
| 'setWorkspaceFocusedSession'
|
|
| 'toggleWorkspaceViewMode'
|
|
// Terminal / chrome mutators previously read only via App session runtime.
|
|
| 'createLocalTerminal'
|
|
| 'createSerialSession'
|
|
| 'connectToHost'
|
|
| 'updateSessionStatus'
|
|
| 'updateSessionFontSize'
|
|
| 'clearSessionFontSizeOverride'
|
|
| 'createWorkspaceWithHosts'
|
|
| 'createWorkspaceFromSessions'
|
|
| 'addSessionToWorkspace'
|
|
| 'appendHostToWorkspace'
|
|
| 'appendLocalTerminalToWorkspace'
|
|
| 'createWorkspaceFromTargets'
|
|
| 'updateSplitSizes'
|
|
| 'splitSession'
|
|
| 'reorderWorkspaceSessions'
|
|
| 'moveFocusInWorkspace'
|
|
| 'runSnippet'
|
|
| 'getOrderedWorkTabs'
|
|
| 'reorderTabs'
|
|
| 'toggleBroadcast'
|
|
| 'isBroadcastEnabled'
|
|
| 'toggleGlobalBroadcast'
|
|
| 'isGlobalBroadcastEnabled'
|
|
| 'canUseGlobalBroadcast'
|
|
| 'copySession'
|
|
| 'copyWorkspace'
|
|
| 'createSessionFromCloneSource'
|
|
| 'updateSessionRestoreCwd'
|
|
| 'getSessionRestoreCwd'
|
|
| 'updateSessionDynamicTitle'
|
|
| 'updateSessionCodingCliProvider'
|
|
>;
|
|
|
|
const EMPTY_SESSIONS: readonly TerminalSession[] = Object.freeze([]);
|
|
const EMPTY_WORKSPACES: readonly Workspace[] = Object.freeze([]);
|
|
const EMPTY_LOG_VIEWS: readonly LogView[] = Object.freeze([]);
|
|
|
|
export const EMPTY_SESSION_SNAPSHOT: SessionSnapshot = Object.freeze({
|
|
sessions: EMPTY_SESSIONS,
|
|
orphanSessions: EMPTY_SESSIONS,
|
|
workspaces: EMPTY_WORKSPACES,
|
|
logViews: EMPTY_LOG_VIEWS,
|
|
draggingSessionId: null,
|
|
sessionRenameTarget: null,
|
|
workspaceRenameTarget: null,
|
|
});
|
|
|
|
const SNAPSHOT_KEYS = Object.keys(EMPTY_SESSION_SNAPSHOT) as (keyof SessionSnapshot)[];
|
|
|
|
export function sessionSnapshotsEqual(
|
|
prev: SessionSnapshot,
|
|
next: SessionSnapshot,
|
|
): boolean {
|
|
return SNAPSHOT_KEYS.every((key) => prev[key] === next[key]);
|
|
}
|
|
|
|
/**
|
|
* External store for the session catalog so TopTabs, the terminal layer and
|
|
* the host tree can subscribe directly instead of receiving sessions through
|
|
* the App domain bags.
|
|
*/
|
|
class SessionSnapshotStore {
|
|
private snapshot: SessionSnapshot = EMPTY_SESSION_SNAPSHOT;
|
|
private actions: SessionSnapshotActions | null = null;
|
|
private listeners = new Set<Listener>();
|
|
private actionListeners = new Set<Listener>();
|
|
|
|
getSnapshot = (): SessionSnapshot => this.snapshot;
|
|
|
|
subscribe = (listener: Listener): (() => void) => {
|
|
this.listeners.add(listener);
|
|
return () => {
|
|
this.listeners.delete(listener);
|
|
};
|
|
};
|
|
|
|
setSnapshot(next: SessionSnapshot): void {
|
|
if (sessionSnapshotsEqual(this.snapshot, next)) return;
|
|
this.snapshot = next;
|
|
for (const listener of this.listeners) {
|
|
listener();
|
|
}
|
|
}
|
|
|
|
getActions = (): SessionSnapshotActions | null => this.actions;
|
|
|
|
subscribeActions = (listener: Listener): (() => void) => {
|
|
this.actionListeners.add(listener);
|
|
return () => {
|
|
this.actionListeners.delete(listener);
|
|
};
|
|
};
|
|
|
|
setActions(next: SessionSnapshotActions | null): void {
|
|
if (this.actions === next) return;
|
|
this.actions = next;
|
|
for (const listener of this.actionListeners) {
|
|
listener();
|
|
}
|
|
}
|
|
}
|
|
|
|
export const sessionSnapshotStore = new SessionSnapshotStore();
|
|
|
|
export function publishSessionSnapshot(snapshot: SessionSnapshot): void {
|
|
sessionSnapshotStore.setSnapshot(snapshot);
|
|
}
|
|
|
|
export function getSessionSnapshot(): SessionSnapshot {
|
|
return sessionSnapshotStore.getSnapshot();
|
|
}
|
|
|
|
export function subscribeSessionSnapshot(listener: Listener): () => void {
|
|
return sessionSnapshotStore.subscribe(listener);
|
|
}
|
|
|
|
export function registerSessionSnapshotActions(
|
|
actions: SessionSnapshotActions | null,
|
|
): void {
|
|
sessionSnapshotStore.setActions(actions);
|
|
}
|
|
|
|
export function getSessionSnapshotActions(): SessionSnapshotActions | null {
|
|
return sessionSnapshotStore.getActions();
|
|
}
|
|
|
|
export function subscribeSessionSnapshotActions(listener: Listener): () => void {
|
|
return sessionSnapshotStore.subscribeActions(listener);
|
|
}
|
|
|
|
/** Subscribe to the whole session catalog. Prefer a narrower selector hook. */
|
|
export function useSessionSnapshot(): SessionSnapshot {
|
|
return useSyncExternalStore(
|
|
subscribeSessionSnapshot,
|
|
getSessionSnapshot,
|
|
getSessionSnapshot,
|
|
);
|
|
}
|
|
|
|
/** Subscribe to one session field so unrelated session churn stays out. */
|
|
export function useSessionSnapshotField<K extends keyof SessionSnapshot>(
|
|
field: K,
|
|
): SessionSnapshot[K] {
|
|
const read = () => getSessionSnapshot()[field];
|
|
return useSyncExternalStore(subscribeSessionSnapshot, read, read);
|
|
}
|
|
|
|
export function useSessionSnapshotActions(): SessionSnapshotActions | null {
|
|
return useSyncExternalStore(
|
|
subscribeSessionSnapshotActions,
|
|
getSessionSnapshotActions,
|
|
getSessionSnapshotActions,
|
|
);
|
|
}
|