mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 10:57:07 +00:00
* feat(app): open files and subagents in a closable sheet on compact On phones, opening a file, a provider subagent, or a Paseo subagent now shows it in a bottom sheet over the current tab instead of adding a tab, so the tab strip stays for terminals and root agents. Paseo subagents get "Open as tab" to move the same tab (same tabId and state) into a full tab with a composer; agents in a sheet hide the composer. The decision lives in the workspace layout store's openTab, driven by a per-kind `compactSheet` rule in the panel manifest, so every open path (chat links, Explorer, subagents track, notifications, plugins) follows it without call-site branching, and a new kind opts in with one manifest entry. The app shell publishes the layout mode (compact / single-pane / split-panes) once, so callers stop passing isCompact for placement. A sheet holding unsaved edits is never implicitly displaced; later opens go to tabs underneath, and closing it goes through the existing discard guard. The editor host is position: sticky because CodeMirror's scrollIntoView otherwise scrolls the bottom sheet's container and drags the sheet to full height. * test(app): fail the compact sheet snap check when the sheet is oversized --------- Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2207 lines
80 KiB
TypeScript
2207 lines
80 KiB
TypeScript
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
import { useEffect, useState } from "react";
|
|
import { create } from "zustand";
|
|
import { persist } from "zustand/middleware";
|
|
import type { z } from "zod";
|
|
import { WorkspaceLayoutPersistedStateSchema } from "./workspace-layout-storage";
|
|
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
|
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
|
import {
|
|
defaultWorkspaceLayoutIds,
|
|
type WorkspaceLayoutIdSource,
|
|
} from "@/stores/workspace-layout-ids";
|
|
import {
|
|
canDismissPaneInLayout,
|
|
clampNormalizedSizes,
|
|
closePaneInLayout,
|
|
closeTabInLayout,
|
|
collectAllPanes,
|
|
collectAllTabs,
|
|
convertDraftToAgentInLayout,
|
|
createTabInLayout,
|
|
createDefaultLayout,
|
|
DEFAULT_PANE_ID,
|
|
AMBIENT_PLACEMENT,
|
|
createWorkspaceLayoutWithExplorerSidebar,
|
|
FOCUSED_PANE_PLACEMENT,
|
|
EXPLORER_SIDEBAR_PANE_ID,
|
|
findPaneById,
|
|
findPaneContainingTab,
|
|
focusPaneInLayout,
|
|
focusTabInLayout,
|
|
getFocusedBrowserId,
|
|
getTreeDepth,
|
|
insertSplit,
|
|
moveTabToPaneInLayout,
|
|
normalizeLayout,
|
|
openTabInLayoutBackground,
|
|
replaceTabTargetInLayout,
|
|
revealTargetInLayout,
|
|
restoreWorkspaceLayout,
|
|
reconcileWorkspaceTabs,
|
|
removePaneFromTree,
|
|
removeTabFromTree,
|
|
reorderFocusedPaneTabsInLayout,
|
|
reorderPaneTabsInLayout,
|
|
setPaneHiddenInLayout,
|
|
setTabStateInLayout,
|
|
selectTabInPaneInLayout,
|
|
splitPaneEmptyInLayout,
|
|
splitWorkspaceRootRightInLayout,
|
|
splitPaneInLayout,
|
|
stripEphemeralTabsFromLayout,
|
|
type SplitGroup,
|
|
type SplitNode,
|
|
type SplitPane,
|
|
type WorkspaceTabPlacement,
|
|
type WorkspaceTabInsertionPosition,
|
|
type WorkspaceTabReconcileState,
|
|
type WorkspaceTabSnapshot,
|
|
type WorkspaceLayout,
|
|
} from "@/stores/workspace-layout-actions";
|
|
import {
|
|
buildDeterministicWorkspaceTabId,
|
|
normalizeWorkspaceTabTarget,
|
|
} from "@/workspace-tabs/identity";
|
|
import { isSheetTabModified, resolveCompactSheetPolicy } from "@/workspace-tabs/compact-sheet";
|
|
import { panelResourceKey, type CompactSheetPolicy } from "@/panels/panel-manifest";
|
|
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
|
import { panelTargetSupportsHostForWorkspaceKey } from "@/plugins/workspace-panels/locations";
|
|
|
|
export {
|
|
AMBIENT_PLACEMENT,
|
|
canDismissPaneInLayout,
|
|
collectAllPanes,
|
|
collectAllTabs,
|
|
createDefaultLayout,
|
|
DEFAULT_PANE_ID,
|
|
createWorkspaceLayoutWithExplorerSidebar,
|
|
FOCUSED_PANE_PLACEMENT,
|
|
findPaneById,
|
|
findPaneContainingTab,
|
|
getFocusedBrowserId,
|
|
getTreeDepth,
|
|
insertSplit,
|
|
normalizeLayout,
|
|
removePaneFromTree,
|
|
removeTabFromTree,
|
|
stripEphemeralTabsFromLayout,
|
|
};
|
|
export type {
|
|
SplitGroup,
|
|
SplitNode,
|
|
SplitPane,
|
|
WorkspaceLayout,
|
|
WorkspaceTabPlacement,
|
|
WorkspaceTabReconcileState,
|
|
WorkspaceTabSnapshot,
|
|
};
|
|
|
|
export type WorkspaceTabOpenIntent = "new" | "reveal" | "background";
|
|
export interface OpenWorkspaceTabInput {
|
|
workspaceKey: string;
|
|
target: WorkspaceTabTarget;
|
|
intent: WorkspaceTabOpenIntent;
|
|
/** Keeps an explicitly opened agent visible even when it is archived. */
|
|
pin?: boolean;
|
|
placement?: WorkspaceTabPlacement;
|
|
insertionPosition?: WorkspaceTabInsertionPosition;
|
|
parentTabId?: string;
|
|
state?: JsonValue;
|
|
}
|
|
|
|
/**
|
|
* How the app shell lays out a workspace: compact (one tab at a time, plus the sheet), a single
|
|
* wide pane (touch tablets, which cannot render splits), or split panes.
|
|
*/
|
|
export type WorkspaceLayoutMode = "compact" | "single-pane" | "split-panes";
|
|
|
|
/** A tab hosted by the compact workspace sheet instead of a pane. */
|
|
export interface WorkspaceSheet {
|
|
tab: WorkspaceTab;
|
|
policy: CompactSheetPolicy;
|
|
parentTabId: string | null;
|
|
}
|
|
|
|
interface WorkspaceLayoutStore {
|
|
/** Published once by the app shell; callers never pass a form factor to open a tab. */
|
|
layoutMode: WorkspaceLayoutMode;
|
|
/** At most one sheet tab per workspace. Not persisted: a reload returns to the tab strip. */
|
|
sheetByWorkspace: Record<string, WorkspaceSheet>;
|
|
layoutByWorkspace: Record<string, WorkspaceLayout>;
|
|
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
|
|
explorerSidebarWidthByWorkspace: Record<string, number>;
|
|
pinnedAgentIdsByWorkspace: Record<string, Set<string>>;
|
|
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
|
|
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
|
|
explorerSidebarPaneIdByWorkspace: Record<string, string | null>;
|
|
sidePaneIdByWorkspace: Record<string, string | null>;
|
|
/** Workspaces where PR detection already added its tab once; a closed tab never returns. */
|
|
pullRequestTabAutoOpenedByWorkspace: Record<string, true>;
|
|
/**
|
|
* The one entry point for showing a target. On compact layouts a target whose panel declares
|
|
* `compactSheet` opens in the workspace sheet; the returned tab id is the sheet tab's id.
|
|
*/
|
|
openTab: (input: OpenWorkspaceTabInput) => string | null;
|
|
/** Whether `openTab` with this input would show the target in the sheet. */
|
|
opensInSheet: (
|
|
input: Pick<OpenWorkspaceTabInput, "workspaceKey" | "target" | "intent">,
|
|
) => boolean;
|
|
setLayoutMode: (mode: WorkspaceLayoutMode) => void;
|
|
closeSheet: (workspaceKey: string) => void;
|
|
/** Moves the sheet's tab, keeping its tab id, into the focused pane. */
|
|
openSheetAsTab: (workspaceKey: string) => string | null;
|
|
/** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */
|
|
autoOpenPullRequestTab: (
|
|
workspaceKey: string,
|
|
resolvePlacement: () => Pick<OpenWorkspaceTabInput, "placement" | "insertionPosition">,
|
|
) => string | null;
|
|
/** Reveals the Explorer sidebar without selecting a view. Returns its pane id. */
|
|
showExplorerSidebar: (workspaceKey: string) => string | null;
|
|
hideExplorerSidebar: (workspaceKey: string) => void;
|
|
/** Returns the ordinary right-side workspace pane, creating it when absent. */
|
|
ensureSidePane: (workspaceKey: string, options?: { focus: boolean }) => string | null;
|
|
closeTab: (workspaceKey: string, tabId: string) => void;
|
|
focusTab: (workspaceKey: string, tabId: string) => void;
|
|
selectTabInPane: (workspaceKey: string, paneId: string, tabId: string) => void;
|
|
replaceTab: (
|
|
workspaceKey: string,
|
|
tabId: string,
|
|
target: WorkspaceTabTarget,
|
|
state?: JsonValue,
|
|
) => string | null;
|
|
setTabState: (workspaceKey: string, tabId: string, state: JsonValue | undefined) => void;
|
|
convertDraftToAgent: (workspaceKey: string, tabId: string, agentId: string) => string | null;
|
|
reconcileTabs: (workspaceKey: string, snapshot: WorkspaceTabSnapshot) => void;
|
|
reorderTabs: (workspaceKey: string, tabIds: string[]) => void;
|
|
getWorkspaceTabs: (workspaceKey: string) => WorkspaceTab[];
|
|
splitPane: (
|
|
workspaceKey: string,
|
|
input: {
|
|
tabId: string;
|
|
targetPaneId: string;
|
|
position: "left" | "right" | "top" | "bottom";
|
|
},
|
|
) => string | null;
|
|
splitPaneEmpty: (
|
|
workspaceKey: string,
|
|
input: {
|
|
targetPaneId: string;
|
|
position: "left" | "right" | "top" | "bottom";
|
|
},
|
|
) => string | null;
|
|
moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void;
|
|
/**
|
|
* Dismisses the pane along with whatever it still holds. The Explorer hides so the
|
|
* user can bring it back; every ordinary pane is removed. Callers own tab teardown
|
|
* (archiving agents, killing terminals) first. No-ops on the last visible pane.
|
|
*/
|
|
closePane: (workspaceKey: string, paneId: string) => void;
|
|
focusPane: (workspaceKey: string, paneId: string) => void;
|
|
unfocusPane: (workspaceKey: string) => string | null;
|
|
restorePaneFocus: (workspaceKey: string, token: string) => void;
|
|
resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void;
|
|
resizeExplorerSidebar: (workspaceKey: string, width: number) => void;
|
|
reorderTabsInPane: (workspaceKey: string, paneId: string, tabIds: string[]) => void;
|
|
unpinAgent: (workspaceKey: string, agentId: string) => void;
|
|
hideAgent: (workspaceKey: string, agentId: string) => void;
|
|
unhideAgent: (workspaceKey: string, agentId: string) => void;
|
|
purgeWorkspace: (workspaceKey: string) => void;
|
|
}
|
|
|
|
interface WorkspaceFocusRestorationState {
|
|
restorePaneId: string | null;
|
|
tokens: string[];
|
|
}
|
|
|
|
// The persisted tree includes the Explorer shell; the renderer docks it outside workspace splits.
|
|
// Preserve four user-created split levels beneath that bookkeeping node.
|
|
const MAX_TREE_DEPTH = 5;
|
|
|
|
const LEGACY_EXPLORER_SIDEBAR_REFERENCE_WIDTH = 1440;
|
|
const WORKSPACE_LAYOUT_PERSIST_VERSION = 2;
|
|
|
|
function convertLegacyExplorerSidebarRatios(
|
|
ratiosByWorkspace: Record<string, number>,
|
|
): Record<string, number> {
|
|
return Object.fromEntries(
|
|
Object.entries(ratiosByWorkspace).map(([workspaceKey, ratio]) => [
|
|
workspaceKey,
|
|
ratio * LEGACY_EXPLORER_SIDEBAR_REFERENCE_WIDTH,
|
|
]),
|
|
);
|
|
}
|
|
|
|
function replacePaneInNode(node: SplitNode, paneId: string, replacement: SplitPane): SplitNode {
|
|
if (node.kind === "pane") {
|
|
return node.pane.id === paneId ? { kind: "pane", pane: replacement } : node;
|
|
}
|
|
return {
|
|
kind: "group",
|
|
group: {
|
|
...node.group,
|
|
children: node.group.children.map((child) => replacePaneInNode(child, paneId, replacement)),
|
|
},
|
|
};
|
|
}
|
|
|
|
function visiblePane<TPane extends SplitPane>(pane: TPane): Omit<TPane, "hidden"> {
|
|
const { hidden: _hidden, ...visible } = pane;
|
|
return visible;
|
|
}
|
|
|
|
function createExplorerSidebarNode(): SplitNode {
|
|
const layout = createWorkspaceLayoutWithExplorerSidebar();
|
|
const pane = findPaneById(layout.root, EXPLORER_SIDEBAR_PANE_ID);
|
|
if (!pane) {
|
|
throw new Error("Default Explorer pane is missing");
|
|
}
|
|
return { kind: "pane", pane };
|
|
}
|
|
|
|
function preserveVersionOneSideTabs(input: {
|
|
layout: WorkspaceLayout;
|
|
legacyExplorerPane: SplitPane;
|
|
rememberedSidePane: SplitPane | null;
|
|
tabs: WorkspaceTab[];
|
|
ids: WorkspaceLayoutIdSource;
|
|
}): { root: SplitNode; sidePaneId: string | null } {
|
|
const rootWithoutExplorer = removePaneFromTree(input.layout.root, input.legacyExplorerPane.id);
|
|
if (input.tabs.length === 0) {
|
|
return {
|
|
root: findPaneById(rootWithoutExplorer, input.legacyExplorerPane.id)
|
|
? createDefaultLayout().root
|
|
: rootWithoutExplorer,
|
|
sidePaneId: input.rememberedSidePane?.id ?? null,
|
|
};
|
|
}
|
|
|
|
if (input.rememberedSidePane) {
|
|
const rememberedTabs = collectAllTabs(input.layout.root).filter((tab) =>
|
|
input.rememberedSidePane?.tabIds.includes(tab.tabId),
|
|
);
|
|
const nextSidePane = visiblePane({
|
|
...input.rememberedSidePane,
|
|
tabIds: [...input.rememberedSidePane.tabIds, ...input.tabs.map((tab) => tab.tabId)],
|
|
tabs: [...rememberedTabs, ...input.tabs],
|
|
focusedTabId: input.rememberedSidePane.focusedTabId ?? input.tabs[0]?.tabId ?? null,
|
|
});
|
|
return {
|
|
root: replacePaneInNode(rootWithoutExplorer, input.rememberedSidePane.id, nextSidePane),
|
|
sidePaneId: input.rememberedSidePane.id,
|
|
};
|
|
}
|
|
|
|
const sidePaneId = input.ids.createNodeId("pane");
|
|
const sidePane = visiblePane({
|
|
...input.legacyExplorerPane,
|
|
id: sidePaneId,
|
|
tabIds: input.tabs.map((tab) => tab.tabId),
|
|
tabs: input.tabs,
|
|
focusedTabId:
|
|
input.tabs.find((tab) => tab.tabId === input.legacyExplorerPane.focusedTabId)?.tabId ??
|
|
input.tabs[0]?.tabId ??
|
|
null,
|
|
});
|
|
return {
|
|
root: replacePaneInNode(input.layout.root, input.legacyExplorerPane.id, sidePane),
|
|
sidePaneId,
|
|
};
|
|
}
|
|
|
|
function migrateVersionOneWorkspaceLayout(input: {
|
|
layout: WorkspaceLayout;
|
|
legacyExplorerPaneId: string | null | undefined;
|
|
rememberedSidePaneId: string | null | undefined;
|
|
ids: WorkspaceLayoutIdSource;
|
|
}): { layout: WorkspaceLayout; explorerPaneId: string; sidePaneId: string | null } {
|
|
const strippedLayout = stripEphemeralTabsFromLayout(input.layout);
|
|
const legacyExplorerPaneId = resolveExplorerSidebarPaneId(
|
|
strippedLayout,
|
|
input.legacyExplorerPaneId,
|
|
);
|
|
const legacyExplorerPane = findPaneById(strippedLayout.root, legacyExplorerPaneId);
|
|
if (!legacyExplorerPane) {
|
|
const ensured = ensurePersistedExplorerSidebarPane({
|
|
layout: strippedLayout,
|
|
registeredPaneId: null,
|
|
ids: input.ids,
|
|
});
|
|
return {
|
|
layout: ensured?.layout ?? strippedLayout,
|
|
explorerPaneId: ensured?.paneId ?? EXPLORER_SIDEBAR_PANE_ID,
|
|
sidePaneId: input.rememberedSidePaneId ?? null,
|
|
};
|
|
}
|
|
|
|
const preservedTabs = collectAllTabs(strippedLayout.root).filter(
|
|
(tab) =>
|
|
legacyExplorerPane.tabIds.includes(tab.tabId) &&
|
|
tab.target.kind !== "files" &&
|
|
tab.target.kind !== "changes_tree",
|
|
);
|
|
const preservedSide = preserveVersionOneSideTabs({
|
|
layout: strippedLayout,
|
|
legacyExplorerPane,
|
|
rememberedSidePane: findPaneById(strippedLayout.root, input.rememberedSidePaneId ?? null),
|
|
tabs: preservedTabs,
|
|
ids: input.ids,
|
|
});
|
|
const explorerNode = createExplorerSidebarNode();
|
|
const focusedPaneId =
|
|
strippedLayout.focusedPaneId === legacyExplorerPane.id
|
|
? (preservedSide.sidePaneId ?? collectAllPanes(preservedSide.root)[0]?.id ?? DEFAULT_PANE_ID)
|
|
: strippedLayout.focusedPaneId;
|
|
return {
|
|
layout: normalizeLayout({
|
|
root: {
|
|
kind: "group",
|
|
group: {
|
|
id: input.ids.createNodeId("group"),
|
|
direction: "horizontal",
|
|
children: [preservedSide.root, explorerNode],
|
|
sizes: [0.78, 0.22],
|
|
},
|
|
},
|
|
focusedPaneId,
|
|
parentTabIdByTabId: strippedLayout.parentTabIdByTabId,
|
|
}),
|
|
explorerPaneId: EXPLORER_SIDEBAR_PANE_ID,
|
|
sidePaneId: preservedSide.sidePaneId,
|
|
};
|
|
}
|
|
|
|
function migrateWorkspaceLayoutPersistedState(
|
|
persistedState: unknown,
|
|
version: number,
|
|
ids: WorkspaceLayoutIdSource,
|
|
): z.infer<typeof WorkspaceLayoutPersistedStateSchema> {
|
|
const result = WorkspaceLayoutPersistedStateSchema.safeParse(persistedState);
|
|
if (!result.success || version >= WORKSPACE_LAYOUT_PERSIST_VERSION) {
|
|
return result.success ? result.data : { layoutByWorkspace: {} };
|
|
}
|
|
|
|
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
|
const explorerPaneIdByWorkspace: Record<string, string | null> = {};
|
|
const sidePaneIdByWorkspace = { ...result.data.sidePaneIdByWorkspace };
|
|
for (const [workspaceKey, layout] of Object.entries(result.data.layoutByWorkspace)) {
|
|
const migrated = migrateVersionOneWorkspaceLayout({
|
|
layout,
|
|
legacyExplorerPaneId: result.data.explorerPaneIdByWorkspace?.[workspaceKey],
|
|
rememberedSidePaneId: result.data.sidePaneIdByWorkspace?.[workspaceKey],
|
|
ids,
|
|
});
|
|
layoutByWorkspace[workspaceKey] = migrated.layout;
|
|
explorerPaneIdByWorkspace[workspaceKey] = migrated.explorerPaneId;
|
|
sidePaneIdByWorkspace[workspaceKey] = migrated.sidePaneId;
|
|
}
|
|
|
|
return {
|
|
...result.data,
|
|
layoutByWorkspace,
|
|
explorerPaneIdByWorkspace,
|
|
sidePaneIdByWorkspace,
|
|
};
|
|
}
|
|
|
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
|
if (typeof value !== "string") {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function createWorkspaceTabInstanceId(): string {
|
|
const value =
|
|
typeof globalThis.crypto?.randomUUID === "function"
|
|
? globalThis.crypto.randomUUID()
|
|
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
return `tab_${value}`;
|
|
}
|
|
|
|
function addAgentIdToWorkspaceSet(
|
|
state: Record<string, Set<string>>,
|
|
workspaceKey: string,
|
|
agentId: string,
|
|
): Record<string, Set<string>> {
|
|
const currentAgentIds = state[workspaceKey] ?? null;
|
|
if (currentAgentIds?.has(agentId)) {
|
|
return state;
|
|
}
|
|
|
|
const nextAgentIds = new Set(currentAgentIds ?? []);
|
|
nextAgentIds.add(agentId);
|
|
return {
|
|
...state,
|
|
[workspaceKey]: nextAgentIds,
|
|
};
|
|
}
|
|
|
|
function removeAgentIdFromWorkspaceSet(
|
|
state: Record<string, Set<string>>,
|
|
workspaceKey: string,
|
|
agentId: string,
|
|
): Record<string, Set<string>> {
|
|
const currentAgentIds = state[workspaceKey] ?? null;
|
|
if (!currentAgentIds?.has(agentId)) {
|
|
return state;
|
|
}
|
|
|
|
if (currentAgentIds.size === 1) {
|
|
const nextState = { ...state };
|
|
delete nextState[workspaceKey];
|
|
return nextState;
|
|
}
|
|
|
|
const nextAgentIds = new Set(currentAgentIds);
|
|
nextAgentIds.delete(agentId);
|
|
return {
|
|
...state,
|
|
[workspaceKey]: nextAgentIds,
|
|
};
|
|
}
|
|
|
|
function getWorkspaceLayout(
|
|
state: Record<string, WorkspaceLayout>,
|
|
workspaceKey: string,
|
|
): WorkspaceLayout {
|
|
return normalizeLayout(state[workspaceKey] ?? createWorkspaceLayoutWithExplorerSidebar());
|
|
}
|
|
|
|
function keepWorkspaceFocusOutOfExplorerSidebar(
|
|
layout: WorkspaceLayout,
|
|
explorerSidebarPaneId: string | null,
|
|
preferredMainPaneId?: string | null,
|
|
): WorkspaceLayout {
|
|
if (!explorerSidebarPaneId || layout.focusedPaneId !== explorerSidebarPaneId) {
|
|
return layout;
|
|
}
|
|
const panes = collectAllPanes(layout.root);
|
|
const preferredPane = panes.find(
|
|
(pane) => pane.id === preferredMainPaneId && pane.id !== explorerSidebarPaneId,
|
|
);
|
|
const mainPane =
|
|
preferredPane ??
|
|
panes.find((pane) => pane.id === DEFAULT_PANE_ID && pane.id !== explorerSidebarPaneId) ??
|
|
panes.find((pane) => pane.id !== explorerSidebarPaneId);
|
|
return { ...layout, focusedPaneId: mainPane?.id ?? null };
|
|
}
|
|
|
|
type ExplorerSidebarState = Pick<
|
|
WorkspaceLayoutStore,
|
|
"layoutByWorkspace" | "explorerSidebarPaneIdByWorkspace"
|
|
>;
|
|
|
|
/**
|
|
* The Explorer sidebar's pane, on screen or not. A workspace the user has not laid out
|
|
* yet still has one — the default layout is born with it — so this answers before
|
|
* the first tab exists.
|
|
*/
|
|
export function selectExplorerSidebarPaneId(
|
|
state: ExplorerSidebarState,
|
|
workspaceKey: string,
|
|
): string | null {
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, workspaceKey);
|
|
return resolveExplorerSidebarPaneId(layout, state.explorerSidebarPaneIdByWorkspace[workspaceKey]);
|
|
}
|
|
|
|
/** A workspace's panes give up keyboard focus while focus is parked or the sheet covers them. */
|
|
export function selectPaneFocusSuspended(
|
|
state: Pick<WorkspaceLayoutStore, "focusRestorationByWorkspace" | "sheetByWorkspace">,
|
|
workspaceKey: string,
|
|
): boolean {
|
|
return (
|
|
Boolean(state.focusRestorationByWorkspace[workspaceKey]?.restorePaneId) ||
|
|
workspaceKey in state.sheetByWorkspace
|
|
);
|
|
}
|
|
|
|
/** Whether the Explorer sidebar pane is currently on screen. */
|
|
export function selectIsExplorerSidebarVisible(
|
|
state: ExplorerSidebarState,
|
|
workspaceKey: string,
|
|
): boolean {
|
|
const layout = state.layoutByWorkspace[workspaceKey];
|
|
const paneId = layout ? selectExplorerSidebarPaneId(state, workspaceKey) : null;
|
|
const pane = paneId && layout ? findPaneById(layout.root, paneId) : null;
|
|
return Boolean(pane && pane.hidden !== true);
|
|
}
|
|
|
|
export function resolveExplorerSidebarPaneId(
|
|
layout: WorkspaceLayout,
|
|
registeredPaneId: string | null | undefined,
|
|
): string | null {
|
|
const registeredPane = findPaneById(layout.root, registeredPaneId ?? null);
|
|
if (registeredPane) {
|
|
return registeredPane.id;
|
|
}
|
|
const defaultPane = findPaneById(layout.root, EXPLORER_SIDEBAR_PANE_ID);
|
|
return defaultPane?.id ?? null;
|
|
}
|
|
|
|
function ensurePersistedExplorerSidebarPane(input: {
|
|
layout: WorkspaceLayout;
|
|
registeredPaneId: string | null | undefined;
|
|
ids: WorkspaceLayoutIdSource;
|
|
}): { layout: WorkspaceLayout; paneId: string } | null {
|
|
const existingPaneId = resolveExplorerSidebarPaneId(input.layout, input.registeredPaneId);
|
|
if (existingPaneId) {
|
|
return {
|
|
layout: keepWorkspaceFocusOutOfExplorerSidebar(
|
|
input.layout,
|
|
existingPaneId,
|
|
input.layout.focusedPaneId,
|
|
),
|
|
paneId: existingPaneId,
|
|
};
|
|
}
|
|
const targetPaneId =
|
|
findPaneById(input.layout.root, input.layout.focusedPaneId)?.id ??
|
|
collectAllPanes(input.layout.root)[0]?.id;
|
|
if (!targetPaneId) {
|
|
return null;
|
|
}
|
|
const split = splitPaneEmptyInLayout({
|
|
layout: input.layout,
|
|
targetPaneId,
|
|
position: "right",
|
|
createNodeId: input.ids.createNodeId,
|
|
maxTreeDepth: MAX_TREE_DEPTH,
|
|
});
|
|
if (!split) {
|
|
return null;
|
|
}
|
|
const hiddenLayout = setPaneHiddenInLayout({
|
|
layout: split.layout,
|
|
paneId: split.paneId,
|
|
hidden: true,
|
|
});
|
|
const seededLayout = restoreWorkspaceLayout(
|
|
stripEphemeralTabsFromLayout(hiddenLayout ?? split.layout),
|
|
split.paneId,
|
|
);
|
|
return {
|
|
layout: seededLayout,
|
|
paneId: split.paneId,
|
|
};
|
|
}
|
|
|
|
function getOpenTabPlacement(
|
|
state: WorkspaceLayoutStore,
|
|
workspaceKey: string,
|
|
target: WorkspaceTabTarget,
|
|
placement: WorkspaceTabPlacement | undefined,
|
|
): {
|
|
layout: WorkspaceLayout;
|
|
placement: WorkspaceTabPlacement;
|
|
explorerSidebarPaneId: string | null;
|
|
} {
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, workspaceKey);
|
|
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
|
layout,
|
|
state.explorerSidebarPaneIdByWorkspace[workspaceKey],
|
|
);
|
|
const requestedPlacement = placement ?? AMBIENT_PLACEMENT;
|
|
const supportsPane = (pane: SplitPane) =>
|
|
panelTargetSupportsHostForWorkspaceKey(
|
|
workspaceKey,
|
|
target,
|
|
pane.id === explorerSidebarPaneId ? "explorer" : "main",
|
|
);
|
|
const requestedPaneId =
|
|
requestedPlacement.mode === "pane" || requestedPlacement.mode === "prefer"
|
|
? requestedPlacement.paneId
|
|
: layout.focusedPaneId;
|
|
const requestedPane = findPaneById(layout.root, requestedPaneId);
|
|
const fallbackPane = collectAllPanes(layout.root).find(
|
|
(pane) => pane.hidden !== true && supportsPane(pane),
|
|
);
|
|
let resolvedPlacement = requestedPlacement;
|
|
if ((!requestedPane || !supportsPane(requestedPane)) && fallbackPane) {
|
|
resolvedPlacement = {
|
|
mode: requestedPlacement.mode === "pane" ? "pane" : "prefer",
|
|
paneId: fallbackPane.id,
|
|
};
|
|
}
|
|
return {
|
|
layout,
|
|
placement: resolvedPlacement,
|
|
explorerSidebarPaneId,
|
|
};
|
|
}
|
|
|
|
function withoutFocusRestoration(
|
|
state: WorkspaceLayoutStore,
|
|
workspaceKey: string,
|
|
): Pick<WorkspaceLayoutStore, "focusRestorationByWorkspace"> | null {
|
|
if (!(workspaceKey in state.focusRestorationByWorkspace)) {
|
|
return null;
|
|
}
|
|
const { [workspaceKey]: _removed, ...focusRestorationByWorkspace } =
|
|
state.focusRestorationByWorkspace;
|
|
return { focusRestorationByWorkspace };
|
|
}
|
|
|
|
function reconcileRememberedSidePane(
|
|
state: WorkspaceLayoutStore,
|
|
workspaceKey: string,
|
|
layout: WorkspaceLayout,
|
|
): Pick<WorkspaceLayoutStore, "sidePaneIdByWorkspace"> {
|
|
const paneId = state.sidePaneIdByWorkspace[workspaceKey];
|
|
return {
|
|
sidePaneIdByWorkspace:
|
|
paneId && !findPaneById(layout.root, paneId)
|
|
? { ...state.sidePaneIdByWorkspace, [workspaceKey]: null }
|
|
: state.sidePaneIdByWorkspace,
|
|
};
|
|
}
|
|
|
|
function attachParentTab(input: {
|
|
layout: WorkspaceLayout;
|
|
childTabId: string | null;
|
|
parentTabId: string | null;
|
|
}): WorkspaceLayout {
|
|
const childTabId = trimNonEmpty(input.childTabId);
|
|
const parentTabId = trimNonEmpty(input.parentTabId);
|
|
if (!childTabId || !parentTabId || childTabId === parentTabId) {
|
|
return normalizeLayout(input.layout);
|
|
}
|
|
|
|
const openTabIds = new Set(collectAllTabs(input.layout.root).map((tab) => tab.tabId));
|
|
if (!openTabIds.has(childTabId) || !openTabIds.has(parentTabId)) {
|
|
return normalizeLayout(input.layout);
|
|
}
|
|
|
|
return normalizeLayout({
|
|
...input.layout,
|
|
parentTabIdByTabId: {
|
|
...input.layout.parentTabIdByTabId,
|
|
[childTabId]: parentTabId,
|
|
},
|
|
});
|
|
}
|
|
|
|
function withoutSheet(
|
|
state: Pick<WorkspaceLayoutStore, "sheetByWorkspace">,
|
|
workspaceKey: string,
|
|
): Pick<WorkspaceLayoutStore, "sheetByWorkspace"> {
|
|
if (!(workspaceKey in state.sheetByWorkspace)) {
|
|
return { sheetByWorkspace: state.sheetByWorkspace };
|
|
}
|
|
const { [workspaceKey]: _removed, ...sheetByWorkspace } = state.sheetByWorkspace;
|
|
return { sheetByWorkspace };
|
|
}
|
|
|
|
function isTargetOpenAsTab(layout: WorkspaceLayout | undefined, target: WorkspaceTabTarget) {
|
|
if (!layout) return false;
|
|
const resourceKey = panelResourceKey(target);
|
|
return collectAllTabs(layout.root).some((tab) => panelResourceKey(tab.target) === resourceKey);
|
|
}
|
|
|
|
function resolveSheetPolicy(
|
|
state: Pick<WorkspaceLayoutStore, "layoutMode" | "layoutByWorkspace">,
|
|
workspaceKey: string,
|
|
target: WorkspaceTabTarget,
|
|
intent: WorkspaceTabOpenIntent,
|
|
): CompactSheetPolicy | null {
|
|
if (state.layoutMode !== "compact" || intent === "background") return null;
|
|
if (intent === "reveal" && isTargetOpenAsTab(state.layoutByWorkspace[workspaceKey], target)) {
|
|
return null;
|
|
}
|
|
return resolveCompactSheetPolicy(workspaceKey, target);
|
|
}
|
|
|
|
/**
|
|
* The parent a newly opened tab links to. A target opened from inside the sheet names the sheet's
|
|
* tab, which the open replaces or closes, so it inherits the sheet's own parent instead.
|
|
*/
|
|
function resolveOpenParentTabId(
|
|
state: Pick<WorkspaceLayoutStore, "sheetByWorkspace">,
|
|
workspaceKey: string,
|
|
parentTabId: string | undefined,
|
|
): string | null {
|
|
const sheet = state.sheetByWorkspace[workspaceKey];
|
|
if (sheet && parentTabId === sheet.tab.tabId) return sheet.parentTabId;
|
|
return parentTabId ?? null;
|
|
}
|
|
|
|
/** A reveal of the resource the sheet already shows keeps the sheet's tab. */
|
|
function sheetKeepsTabFor(
|
|
sheet: WorkspaceSheet | undefined,
|
|
target: WorkspaceTabTarget,
|
|
intent: WorkspaceTabOpenIntent,
|
|
): sheet is WorkspaceSheet {
|
|
return (
|
|
intent === "reveal" &&
|
|
sheet !== undefined &&
|
|
panelResourceKey(sheet.tab.target) === panelResourceKey(target)
|
|
);
|
|
}
|
|
|
|
function placeTargetInSheet(input: {
|
|
/** The sheet whose tab the target keeps, if any. */
|
|
kept: WorkspaceSheet | undefined;
|
|
target: WorkspaceTabTarget;
|
|
intent: WorkspaceTabOpenIntent;
|
|
policy: CompactSheetPolicy;
|
|
parentTabId: string | null;
|
|
state: JsonValue | undefined;
|
|
}): WorkspaceSheet {
|
|
if (input.kept) {
|
|
return { ...input.kept, tab: { ...input.kept.tab, target: input.target } };
|
|
}
|
|
return {
|
|
tab: {
|
|
tabId:
|
|
input.intent === "reveal"
|
|
? buildDeterministicWorkspaceTabId(input.target)
|
|
: createWorkspaceTabInstanceId(),
|
|
target: input.target,
|
|
createdAt: Date.now(),
|
|
...(input.state !== undefined ? { state: input.state } : {}),
|
|
},
|
|
policy: input.policy,
|
|
parentTabId: input.parentTabId,
|
|
};
|
|
}
|
|
|
|
/** Moves a workspace's sheet tab into its focused pane, keeping its tab id and state. */
|
|
function promoteSheetToTab(
|
|
state: WorkspaceLayoutStore,
|
|
workspaceKey: string,
|
|
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
|
|
const sheet = state.sheetByWorkspace[workspaceKey];
|
|
if (!sheet) return null;
|
|
const { tab } = sheet;
|
|
const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT);
|
|
const input = {
|
|
...placement,
|
|
target: tab.target,
|
|
now: tab.createdAt,
|
|
createTabId: () => tab.tabId,
|
|
state: tab.state,
|
|
};
|
|
const result = isTargetOpenAsTab(placement.layout, tab.target)
|
|
? revealTargetInLayout(input)
|
|
: createTabInLayout(input);
|
|
if (!result) return null;
|
|
const nextLayout = attachParentTab({
|
|
layout: keepWorkspaceFocusOutOfExplorerSidebar(
|
|
result.layout,
|
|
placement.explorerSidebarPaneId,
|
|
placement.layout.focusedPaneId,
|
|
),
|
|
childTabId: result.tabId,
|
|
parentTabId: sheet.parentTabId,
|
|
});
|
|
return {
|
|
tabId: result.tabId,
|
|
patch: {
|
|
...withoutSheet(state, workspaceKey),
|
|
...withoutFocusRestoration(state, workspaceKey),
|
|
hiddenAgentIdsByWorkspace:
|
|
tab.target.kind === "agent"
|
|
? removeAgentIdFromWorkspaceSet(
|
|
state.hiddenAgentIdsByWorkspace,
|
|
workspaceKey,
|
|
tab.target.agentId,
|
|
)
|
|
: state.hiddenAgentIdsByWorkspace,
|
|
layoutByWorkspace: { ...state.layoutByWorkspace, [workspaceKey]: nextLayout },
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A sheet holding unsaved work is never implicitly displaced: moving its tab would remount the
|
|
* panel and drop the work. Only the explicit, guarded close removes it.
|
|
*/
|
|
function sheetHoldsUnsavedWork(
|
|
state: Pick<WorkspaceLayoutStore, "sheetByWorkspace">,
|
|
workspaceKey: string,
|
|
): boolean {
|
|
const sheet = state.sheetByWorkspace[workspaceKey];
|
|
return sheet !== undefined && isSheetTabModified(workspaceKey, sheet.tab.tabId);
|
|
}
|
|
|
|
/**
|
|
* Where an open lands in the sheet, or null when it opens as a tab. A reveal of the resource the
|
|
* sheet shows keeps its tab; any other target never displaces unsaved work and opens as a tab.
|
|
*/
|
|
function resolveSheetPlacement(
|
|
state: WorkspaceLayoutStore,
|
|
workspaceKey: string,
|
|
target: WorkspaceTabTarget,
|
|
intent: WorkspaceTabOpenIntent,
|
|
): { policy: CompactSheetPolicy; kept: WorkspaceSheet | undefined } | null {
|
|
const policy = resolveSheetPolicy(state, workspaceKey, target, intent);
|
|
if (!policy) return null;
|
|
const sheet = state.sheetByWorkspace[workspaceKey];
|
|
if (sheetKeepsTabFor(sheet, target, intent)) return { policy, kept: sheet };
|
|
return sheetHoldsUnsavedWork(state, workspaceKey) ? null : { policy, kept: undefined };
|
|
}
|
|
|
|
/** Closes the sheet because navigation moved on underneath it, unless it holds unsaved work. */
|
|
function dismissSheet(
|
|
state: WorkspaceLayoutStore,
|
|
workspaceKey: string,
|
|
): Partial<WorkspaceLayoutStore> {
|
|
return sheetHoldsUnsavedWork(state, workspaceKey) ? {} : withoutSheet(state, workspaceKey);
|
|
}
|
|
|
|
/**
|
|
* A sheet agent closes once the workspace no longer has it (archived or gone), like its tab would,
|
|
* and once it stops being a subagent: as a root agent, reconcile gives it its own tab, and the sheet
|
|
* would hold a second tab with the same id.
|
|
*/
|
|
function sheetAgentNoLongerFits(
|
|
workspaceKey: string,
|
|
sheet: WorkspaceSheet | undefined,
|
|
snapshot: WorkspaceTabSnapshot,
|
|
): boolean {
|
|
if (sheet?.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false;
|
|
if (!new Set(snapshot.activeAgentIds).has(sheet.tab.target.agentId)) return true;
|
|
return resolveCompactSheetPolicy(workspaceKey, sheet.tab.target) === null;
|
|
}
|
|
|
|
/**
|
|
* Splits an Explorer sidebar out of the pane the user is in. Only reached by layouts saved
|
|
* before the Explorer became part of the default tree; new ones are born with it.
|
|
*/
|
|
function createExplorerSidebarPane(
|
|
workspaceKey: string,
|
|
layout: WorkspaceLayout,
|
|
splitPaneEmpty: WorkspaceLayoutStore["splitPaneEmpty"],
|
|
): string | null {
|
|
const targetPaneId =
|
|
findPaneById(layout.root, layout.focusedPaneId)?.id ?? collectAllPanes(layout.root)[0]?.id;
|
|
return targetPaneId ? splitPaneEmpty(workspaceKey, { targetPaneId, position: "right" }) : null;
|
|
}
|
|
|
|
export function createWorkspaceLayoutStore(
|
|
ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds,
|
|
) {
|
|
return create<WorkspaceLayoutStore>()(
|
|
persist(
|
|
(set, get) => ({
|
|
autoOpenPullRequestTab: (workspaceKey, resolvePlacement) => {
|
|
const key = trimNonEmpty(workspaceKey);
|
|
if (!key || get().pullRequestTabAutoOpenedByWorkspace[key]) return null;
|
|
const tabId = get().openTab({
|
|
workspaceKey: key,
|
|
target: { kind: "pull_request" },
|
|
intent: "background",
|
|
...resolvePlacement(),
|
|
});
|
|
if (tabId) {
|
|
set((state) => ({
|
|
pullRequestTabAutoOpenedByWorkspace: {
|
|
...state.pullRequestTabAutoOpenedByWorkspace,
|
|
[key]: true,
|
|
},
|
|
}));
|
|
}
|
|
return tabId;
|
|
},
|
|
layoutMode: "split-panes",
|
|
sheetByWorkspace: {},
|
|
layoutByWorkspace: {},
|
|
splitSizesByWorkspace: {},
|
|
explorerSidebarWidthByWorkspace: {},
|
|
pinnedAgentIdsByWorkspace: {},
|
|
hiddenAgentIdsByWorkspace: {},
|
|
focusRestorationByWorkspace: {},
|
|
explorerSidebarPaneIdByWorkspace: {},
|
|
sidePaneIdByWorkspace: {},
|
|
pullRequestTabAutoOpenedByWorkspace: {},
|
|
openTab: (input) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(input.workspaceKey);
|
|
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
|
|
if (!normalizedWorkspaceKey || !normalizedTarget) {
|
|
return null;
|
|
}
|
|
const parentTabId = resolveOpenParentTabId(
|
|
get(),
|
|
normalizedWorkspaceKey,
|
|
input.parentTabId,
|
|
);
|
|
const sheetPlacement = resolveSheetPlacement(
|
|
get(),
|
|
normalizedWorkspaceKey,
|
|
normalizedTarget,
|
|
input.intent,
|
|
);
|
|
if (sheetPlacement) {
|
|
const sheet = placeTargetInSheet({
|
|
kept: sheetPlacement.kept,
|
|
target: normalizedTarget,
|
|
intent: input.intent,
|
|
policy: sheetPlacement.policy,
|
|
parentTabId,
|
|
state: input.state,
|
|
});
|
|
set((state) => ({
|
|
sheetByWorkspace: { ...state.sheetByWorkspace, [normalizedWorkspaceKey]: sheet },
|
|
}));
|
|
return sheet.tab.tabId;
|
|
}
|
|
const placement = getOpenTabPlacement(
|
|
get(),
|
|
normalizedWorkspaceKey,
|
|
normalizedTarget,
|
|
input.placement,
|
|
);
|
|
let result;
|
|
if (input.intent === "new") {
|
|
result = createTabInLayout({
|
|
...placement,
|
|
insertionPosition: input.insertionPosition,
|
|
target: normalizedTarget,
|
|
now: Date.now(),
|
|
createTabId: createWorkspaceTabInstanceId,
|
|
state: input.state,
|
|
});
|
|
} else if (input.intent === "background") {
|
|
result = openTabInLayoutBackground({
|
|
...placement,
|
|
insertionPosition: input.insertionPosition,
|
|
target: normalizedTarget,
|
|
now: Date.now(),
|
|
});
|
|
} else {
|
|
result = revealTargetInLayout({
|
|
...placement,
|
|
insertionPosition: input.insertionPosition,
|
|
target: normalizedTarget,
|
|
now: Date.now(),
|
|
createTabId: createWorkspaceTabInstanceId,
|
|
});
|
|
}
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
const nextLayout = keepWorkspaceFocusOutOfExplorerSidebar(
|
|
result.layout,
|
|
placement.explorerSidebarPaneId,
|
|
placement.layout.focusedPaneId,
|
|
);
|
|
const shouldPinAgent = input.pin === true && normalizedTarget.kind === "agent";
|
|
set((state) => ({
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
// A tab brought to the front dismisses the sheet over it.
|
|
...(input.intent === "background" ? {} : dismissSheet(state, normalizedWorkspaceKey)),
|
|
hiddenAgentIdsByWorkspace:
|
|
normalizedTarget.kind !== "agent"
|
|
? state.hiddenAgentIdsByWorkspace
|
|
: removeAgentIdFromWorkspaceSet(
|
|
state.hiddenAgentIdsByWorkspace,
|
|
normalizedWorkspaceKey,
|
|
normalizedTarget.agentId,
|
|
),
|
|
pinnedAgentIdsByWorkspace: shouldPinAgent
|
|
? addAgentIdToWorkspaceSet(
|
|
state.pinnedAgentIdsByWorkspace,
|
|
normalizedWorkspaceKey,
|
|
normalizedTarget.agentId,
|
|
)
|
|
: state.pinnedAgentIdsByWorkspace,
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: parentTabId
|
|
? attachParentTab({
|
|
layout: nextLayout,
|
|
childTabId: result.tabId,
|
|
parentTabId,
|
|
})
|
|
: nextLayout,
|
|
},
|
|
}));
|
|
return result.tabId;
|
|
},
|
|
opensInSheet: (input) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(input.workspaceKey);
|
|
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
|
|
if (!normalizedWorkspaceKey || !normalizedTarget) return false;
|
|
return (
|
|
resolveSheetPlacement(get(), normalizedWorkspaceKey, normalizedTarget, input.intent) !==
|
|
null
|
|
);
|
|
},
|
|
setLayoutMode: (mode) => {
|
|
set((state) => {
|
|
if (state.layoutMode === mode) return state;
|
|
if (mode === "compact") return { layoutMode: mode };
|
|
// Widening keeps whatever the user had open in the sheet as an ordinary tab.
|
|
const next: WorkspaceLayoutStore = { ...state, layoutMode: mode };
|
|
for (const workspaceKey of Object.keys(state.sheetByWorkspace)) {
|
|
const promoted = promoteSheetToTab(next, workspaceKey);
|
|
Object.assign(next, promoted?.patch ?? withoutSheet(next, workspaceKey));
|
|
}
|
|
return next;
|
|
});
|
|
},
|
|
closeSheet: (workspaceKey) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey) return;
|
|
set((state) => withoutSheet(state, normalizedWorkspaceKey));
|
|
},
|
|
openSheetAsTab: (workspaceKey) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey) return null;
|
|
const promoted = promoteSheetToTab(get(), normalizedWorkspaceKey);
|
|
if (!promoted) return null;
|
|
set(promoted.patch);
|
|
return promoted.tabId;
|
|
},
|
|
showExplorerSidebar: (workspaceKey) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey) {
|
|
return null;
|
|
}
|
|
|
|
const layout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
|
|
const paneId =
|
|
resolveExplorerSidebarPaneId(
|
|
layout,
|
|
get().explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
) ?? createExplorerSidebarPane(normalizedWorkspaceKey, layout, get().splitPaneEmpty);
|
|
if (!paneId) {
|
|
return null;
|
|
}
|
|
|
|
set((state) => {
|
|
const currentLayout = getWorkspaceLayout(
|
|
state.layoutByWorkspace,
|
|
normalizedWorkspaceKey,
|
|
);
|
|
const revealedLayout =
|
|
setPaneHiddenInLayout({ layout: currentLayout, paneId, hidden: false }) ??
|
|
currentLayout;
|
|
return {
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: keepWorkspaceFocusOutOfExplorerSidebar(
|
|
revealedLayout,
|
|
paneId,
|
|
currentLayout.focusedPaneId,
|
|
),
|
|
},
|
|
explorerSidebarPaneIdByWorkspace: {
|
|
...state.explorerSidebarPaneIdByWorkspace,
|
|
[normalizedWorkspaceKey]: paneId,
|
|
},
|
|
};
|
|
});
|
|
return paneId;
|
|
},
|
|
hideExplorerSidebar: (workspaceKey) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
const paneId = resolveExplorerSidebarPaneId(
|
|
layout,
|
|
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
const nextLayout = paneId
|
|
? setPaneHiddenInLayout({ layout, paneId, hidden: true })
|
|
: null;
|
|
if (!nextLayout) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
ensureSidePane: (workspaceKey, options) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey) {
|
|
return null;
|
|
}
|
|
const currentState = get();
|
|
const layout = getWorkspaceLayout(currentState.layoutByWorkspace, normalizedWorkspaceKey);
|
|
const explorerPaneId = resolveExplorerSidebarPaneId(
|
|
layout,
|
|
currentState.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
const rememberedPaneId = trimNonEmpty(
|
|
currentState.sidePaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
const rememberedPane = findPaneById(layout.root, rememberedPaneId);
|
|
if (rememberedPane && rememberedPane.id !== explorerPaneId) {
|
|
return rememberedPane.id;
|
|
}
|
|
|
|
const result = splitWorkspaceRootRightInLayout({
|
|
layout,
|
|
maxTreeDepth: MAX_TREE_DEPTH,
|
|
createNodeId: ids.createNodeId,
|
|
});
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
set((state) => ({
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]:
|
|
options?.focus === false
|
|
? { ...result.layout, focusedPaneId: layout.focusedPaneId }
|
|
: result.layout,
|
|
},
|
|
sidePaneIdByWorkspace: {
|
|
...state.sidePaneIdByWorkspace,
|
|
[normalizedWorkspaceKey]: result.paneId,
|
|
},
|
|
}));
|
|
return result.paneId;
|
|
},
|
|
closeTab: (workspaceKey, tabId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedTabId = trimNonEmpty(tabId);
|
|
if (!normalizedWorkspaceKey || !normalizedTabId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
if (state.sheetByWorkspace[normalizedWorkspaceKey]?.tab.tabId === normalizedTabId) {
|
|
return withoutSheet(state, normalizedWorkspaceKey);
|
|
}
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
|
layout,
|
|
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
const closingPane = findPaneContainingTab(layout.root, normalizedTabId);
|
|
const closingTab = collectAllTabs(layout.root).find(
|
|
(tab) => tab.tabId === normalizedTabId,
|
|
);
|
|
if (closingPane?.tabIds.length === 1 && closingTab?.target.kind === "new_tab") {
|
|
const nextLayout =
|
|
closingPane.id === explorerSidebarPaneId
|
|
? setPaneHiddenInLayout({
|
|
layout,
|
|
paneId: closingPane.id,
|
|
hidden: true,
|
|
})
|
|
: closePaneInLayout({
|
|
layout,
|
|
paneId: closingPane.id,
|
|
explorerSidebarPaneId,
|
|
});
|
|
if (!nextLayout) {
|
|
return state;
|
|
}
|
|
return {
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
...reconcileRememberedSidePane(state, normalizedWorkspaceKey, nextLayout),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
};
|
|
}
|
|
const closedLayout = closeTabInLayout({
|
|
layout,
|
|
tabId: normalizedTabId,
|
|
explorerSidebarPaneId,
|
|
});
|
|
const nextLayoutBeforeFocusNormalization =
|
|
closedLayout &&
|
|
closingPane?.id === explorerSidebarPaneId &&
|
|
closingPane.tabIds.length === 1
|
|
? (setPaneHiddenInLayout({
|
|
layout: closedLayout,
|
|
paneId: explorerSidebarPaneId,
|
|
hidden: true,
|
|
}) ?? closedLayout)
|
|
: closedLayout;
|
|
const nextLayout = nextLayoutBeforeFocusNormalization
|
|
? keepWorkspaceFocusOutOfExplorerSidebar(
|
|
nextLayoutBeforeFocusNormalization,
|
|
explorerSidebarPaneId,
|
|
layout.focusedPaneId,
|
|
)
|
|
: null;
|
|
if (!nextLayout) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
...reconcileRememberedSidePane(state, normalizedWorkspaceKey, nextLayout),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
focusTab: (workspaceKey, tabId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedTabId = trimNonEmpty(tabId);
|
|
if (!normalizedWorkspaceKey || !normalizedTabId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
|
layout,
|
|
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
const tabPane = findPaneContainingTab(layout.root, normalizedTabId);
|
|
let nextLayout: WorkspaceLayout | null;
|
|
if (tabPane?.id === explorerSidebarPaneId) {
|
|
const revealedLayout =
|
|
setPaneHiddenInLayout({
|
|
layout,
|
|
paneId: explorerSidebarPaneId,
|
|
hidden: false,
|
|
}) ?? layout;
|
|
nextLayout =
|
|
selectTabInPaneInLayout({
|
|
layout: revealedLayout,
|
|
paneId: explorerSidebarPaneId,
|
|
tabId: normalizedTabId,
|
|
}) ?? revealedLayout;
|
|
} else {
|
|
nextLayout = focusTabInLayout({ layout, tabId: normalizedTabId });
|
|
}
|
|
if (!nextLayout) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
// Choosing a tab dismisses the sheet that covers it.
|
|
...dismissSheet(state, normalizedWorkspaceKey),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
selectTabInPane: (workspaceKey, paneId, tabId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedPaneId = trimNonEmpty(paneId);
|
|
const normalizedTabId = trimNonEmpty(tabId);
|
|
if (!normalizedWorkspaceKey || !normalizedPaneId || !normalizedTabId) {
|
|
return;
|
|
}
|
|
set((state) => {
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
const nextLayout = selectTabInPaneInLayout({
|
|
layout,
|
|
paneId: normalizedPaneId,
|
|
tabId: normalizedTabId,
|
|
});
|
|
return nextLayout
|
|
? {
|
|
// Choosing a tab dismisses the sheet that covers it.
|
|
...dismissSheet(state, normalizedWorkspaceKey),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
}
|
|
: state;
|
|
});
|
|
},
|
|
replaceTab: (workspaceKey, tabId, target, tabState) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedTabId = trimNonEmpty(tabId);
|
|
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
|
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null;
|
|
const sheet = get().sheetByWorkspace[normalizedWorkspaceKey];
|
|
if (sheet?.tab.tabId === normalizedTabId) {
|
|
const policy = resolveCompactSheetPolicy(normalizedWorkspaceKey, normalizedTarget);
|
|
const replaced: WorkspaceSheet = {
|
|
...sheet,
|
|
policy: policy ?? sheet.policy,
|
|
tab: {
|
|
...sheet.tab,
|
|
target: normalizedTarget,
|
|
...(tabState !== undefined ? { state: tabState } : {}),
|
|
},
|
|
};
|
|
const next: WorkspaceLayoutStore = {
|
|
...get(),
|
|
sheetByWorkspace: { ...get().sheetByWorkspace, [normalizedWorkspaceKey]: replaced },
|
|
};
|
|
if (policy) {
|
|
set({ sheetByWorkspace: next.sheetByWorkspace });
|
|
return normalizedTabId;
|
|
}
|
|
// A target the sheet does not host becomes the tab it would have opened as.
|
|
const promoted = promoteSheetToTab(next, normalizedWorkspaceKey);
|
|
set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey));
|
|
return promoted?.tabId ?? null;
|
|
}
|
|
const result = replaceTabTargetInLayout({
|
|
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
|
|
tabId: normalizedTabId,
|
|
target: normalizedTarget,
|
|
createTabId: createWorkspaceTabInstanceId,
|
|
state: tabState,
|
|
});
|
|
if (!result) return null;
|
|
set((state) => ({
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
hiddenAgentIdsByWorkspace:
|
|
normalizedTarget.kind !== "agent"
|
|
? state.hiddenAgentIdsByWorkspace
|
|
: removeAgentIdFromWorkspaceSet(
|
|
state.hiddenAgentIdsByWorkspace,
|
|
normalizedWorkspaceKey,
|
|
normalizedTarget.agentId,
|
|
),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: result.layout,
|
|
},
|
|
}));
|
|
return result.tabId;
|
|
},
|
|
setTabState: (workspaceKey, tabId, tabState) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedTabId = trimNonEmpty(tabId);
|
|
if (!normalizedWorkspaceKey || !normalizedTabId) return;
|
|
set((state) => {
|
|
const sheet = state.sheetByWorkspace[normalizedWorkspaceKey];
|
|
if (sheet?.tab.tabId === normalizedTabId) {
|
|
const { state: _previous, ...tab } = sheet.tab;
|
|
return {
|
|
sheetByWorkspace: {
|
|
...state.sheetByWorkspace,
|
|
[normalizedWorkspaceKey]: {
|
|
...sheet,
|
|
tab: tabState === undefined ? tab : { ...tab, state: tabState },
|
|
},
|
|
},
|
|
};
|
|
}
|
|
const layout = setTabStateInLayout({
|
|
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
|
tabId: normalizedTabId,
|
|
state: tabState,
|
|
});
|
|
if (!layout) return state;
|
|
return {
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: layout,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
convertDraftToAgent: (workspaceKey, tabId, agentId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedTabId = trimNonEmpty(tabId);
|
|
const normalizedAgentId = trimNonEmpty(agentId);
|
|
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedAgentId) {
|
|
return null;
|
|
}
|
|
|
|
const layout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
|
|
const result = convertDraftToAgentInLayout({
|
|
layout,
|
|
explorerSidebarPaneId: resolveExplorerSidebarPaneId(
|
|
layout,
|
|
get().explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
),
|
|
tabId: normalizedTabId,
|
|
agentId: normalizedAgentId,
|
|
});
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
|
|
set((state) => ({
|
|
...(result.layout.focusedPaneId !== null
|
|
? (withoutFocusRestoration(state, normalizedWorkspaceKey) ?? {})
|
|
: {}),
|
|
hiddenAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet(
|
|
state.hiddenAgentIdsByWorkspace,
|
|
normalizedWorkspaceKey,
|
|
normalizedAgentId,
|
|
),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: result.layout,
|
|
},
|
|
}));
|
|
|
|
return result.tabId;
|
|
},
|
|
reconcileTabs: (workspaceKey, snapshot) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const rawLayout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
|
rawLayout,
|
|
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
const currentLayout = keepWorkspaceFocusOutOfExplorerSidebar(
|
|
rawLayout,
|
|
explorerSidebarPaneId,
|
|
rawLayout.focusedPaneId,
|
|
);
|
|
const nextState = reconcileWorkspaceTabs(
|
|
{
|
|
layout: currentLayout,
|
|
pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
|
hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
|
explorerSidebarPaneId,
|
|
},
|
|
snapshot,
|
|
);
|
|
const nextLayout = keepWorkspaceFocusOutOfExplorerSidebar(
|
|
nextState.layout,
|
|
explorerSidebarPaneId,
|
|
currentLayout.focusedPaneId,
|
|
);
|
|
let pinnedAgentIdsByWorkspace = state.pinnedAgentIdsByWorkspace;
|
|
for (const agentId of state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? []) {
|
|
if (!nextState.pinnedAgentIds?.has(agentId)) {
|
|
pinnedAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet(
|
|
pinnedAgentIdsByWorkspace,
|
|
normalizedWorkspaceKey,
|
|
agentId,
|
|
);
|
|
}
|
|
}
|
|
const sheetAgentGone = sheetAgentNoLongerFits(
|
|
normalizedWorkspaceKey,
|
|
state.sheetByWorkspace[normalizedWorkspaceKey],
|
|
snapshot,
|
|
);
|
|
if (
|
|
state.layoutByWorkspace[normalizedWorkspaceKey] &&
|
|
nextLayout === rawLayout &&
|
|
pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace &&
|
|
!sheetAgentGone
|
|
) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
...(sheetAgentGone ? withoutSheet(state, normalizedWorkspaceKey) : {}),
|
|
pinnedAgentIdsByWorkspace,
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
reorderTabs: (workspaceKey, tabIds) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const nextLayout = reorderFocusedPaneTabsInLayout({
|
|
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
|
tabIds,
|
|
});
|
|
if (!nextLayout) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
getWorkspaceTabs: (workspaceKey) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey) {
|
|
return [];
|
|
}
|
|
return collectAllTabs(
|
|
getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey).root,
|
|
);
|
|
},
|
|
splitPane: (workspaceKey, input) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedTabId = trimNonEmpty(input.tabId);
|
|
const normalizedTargetPaneId = trimNonEmpty(input.targetPaneId);
|
|
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTargetPaneId) {
|
|
return null;
|
|
}
|
|
|
|
const currentLayout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
|
|
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
|
currentLayout,
|
|
get().explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
if (normalizedTargetPaneId === explorerSidebarPaneId) {
|
|
return null;
|
|
}
|
|
const movingTab = collectAllTabs(currentLayout.root).find(
|
|
(tab) => tab.tabId === normalizedTabId,
|
|
);
|
|
if (
|
|
!movingTab ||
|
|
!panelTargetSupportsHostForWorkspaceKey(
|
|
normalizedWorkspaceKey,
|
|
movingTab.target,
|
|
"main",
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const result = splitPaneInLayout({
|
|
layout: currentLayout,
|
|
tabId: normalizedTabId,
|
|
targetPaneId: normalizedTargetPaneId,
|
|
position: input.position,
|
|
maxTreeDepth: MAX_TREE_DEPTH,
|
|
createNodeId: ids.createNodeId,
|
|
});
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
|
|
set((state) => ({
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: result.layout,
|
|
},
|
|
}));
|
|
|
|
return result.paneId;
|
|
},
|
|
splitPaneEmpty: (workspaceKey, input) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedTargetPaneId = trimNonEmpty(input.targetPaneId);
|
|
if (!normalizedWorkspaceKey || !normalizedTargetPaneId) {
|
|
return null;
|
|
}
|
|
|
|
const currentLayout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
|
|
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
|
currentLayout,
|
|
get().explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
if (normalizedTargetPaneId === explorerSidebarPaneId) {
|
|
return null;
|
|
}
|
|
|
|
const result = splitPaneEmptyInLayout({
|
|
layout: currentLayout,
|
|
targetPaneId: normalizedTargetPaneId,
|
|
position: input.position,
|
|
maxTreeDepth: MAX_TREE_DEPTH,
|
|
createNodeId: ids.createNodeId,
|
|
});
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
|
|
set((state) => ({
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: result.layout,
|
|
},
|
|
}));
|
|
|
|
return result.paneId;
|
|
},
|
|
moveTabToPane: (workspaceKey, tabId, toPaneId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedTabId = trimNonEmpty(tabId);
|
|
const normalizedToPaneId = trimNonEmpty(toPaneId);
|
|
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedToPaneId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
const movingTab = collectAllTabs(layout.root).find(
|
|
(tab) => tab.tabId === normalizedTabId,
|
|
);
|
|
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
|
layout,
|
|
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
const destinationHost =
|
|
normalizedToPaneId === explorerSidebarPaneId ? "explorer" : "main";
|
|
if (
|
|
!movingTab ||
|
|
!panelTargetSupportsHostForWorkspaceKey(
|
|
normalizedWorkspaceKey,
|
|
movingTab.target,
|
|
destinationHost,
|
|
)
|
|
) {
|
|
return state;
|
|
}
|
|
const nextLayout = moveTabToPaneInLayout({
|
|
layout,
|
|
tabId: normalizedTabId,
|
|
toPaneId: normalizedToPaneId,
|
|
explorerSidebarPaneId,
|
|
});
|
|
if (!nextLayout) {
|
|
return state;
|
|
}
|
|
const restoredLayout =
|
|
normalizedToPaneId === explorerSidebarPaneId
|
|
? restoreWorkspaceLayout(nextLayout, explorerSidebarPaneId)
|
|
: nextLayout;
|
|
const normalizedNextLayout = keepWorkspaceFocusOutOfExplorerSidebar(
|
|
restoredLayout,
|
|
explorerSidebarPaneId,
|
|
layout.focusedPaneId,
|
|
);
|
|
const rememberedSidePaneId = state.sidePaneIdByWorkspace[normalizedWorkspaceKey];
|
|
|
|
return {
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: normalizedNextLayout,
|
|
},
|
|
sidePaneIdByWorkspace:
|
|
rememberedSidePaneId &&
|
|
!findPaneById(normalizedNextLayout.root, rememberedSidePaneId)
|
|
? { ...state.sidePaneIdByWorkspace, [normalizedWorkspaceKey]: null }
|
|
: state.sidePaneIdByWorkspace,
|
|
};
|
|
});
|
|
},
|
|
closePane: (workspaceKey, paneId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedPaneId = trimNonEmpty(paneId);
|
|
if (!normalizedWorkspaceKey || !normalizedPaneId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
// The Explorer is a surface the user summons, so closing it puts it
|
|
// away rather than dismantling the split it lives in.
|
|
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
|
layout,
|
|
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
const isExplorerSidebar = explorerSidebarPaneId === normalizedPaneId;
|
|
const nextLayout = isExplorerSidebar
|
|
? setPaneHiddenInLayout({ layout, paneId: normalizedPaneId, hidden: true })
|
|
: closePaneInLayout({
|
|
layout,
|
|
paneId: normalizedPaneId,
|
|
explorerSidebarPaneId,
|
|
});
|
|
if (!nextLayout) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
sidePaneIdByWorkspace:
|
|
state.sidePaneIdByWorkspace[normalizedWorkspaceKey] === normalizedPaneId
|
|
? {
|
|
...state.sidePaneIdByWorkspace,
|
|
[normalizedWorkspaceKey]: null,
|
|
}
|
|
: state.sidePaneIdByWorkspace,
|
|
};
|
|
});
|
|
},
|
|
focusPane: (workspaceKey, paneId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedPaneId = trimNonEmpty(paneId);
|
|
if (!normalizedWorkspaceKey || !normalizedPaneId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
|
layout,
|
|
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
|
);
|
|
if (normalizedPaneId === explorerSidebarPaneId) {
|
|
return state;
|
|
}
|
|
const nextLayout = focusPaneInLayout({
|
|
layout,
|
|
paneId: normalizedPaneId,
|
|
});
|
|
if (!nextLayout) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
unfocusPane: (workspaceKey) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey) {
|
|
return null;
|
|
}
|
|
|
|
const token = ids.createFocusRestorationToken();
|
|
set((state) => {
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
const currentRestoration = state.focusRestorationByWorkspace[normalizedWorkspaceKey];
|
|
const restorePaneId = currentRestoration?.restorePaneId ?? layout.focusedPaneId;
|
|
|
|
return {
|
|
focusRestorationByWorkspace: {
|
|
...state.focusRestorationByWorkspace,
|
|
[normalizedWorkspaceKey]: {
|
|
restorePaneId,
|
|
tokens: [...(currentRestoration?.tokens ?? []), token],
|
|
},
|
|
},
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]:
|
|
layout.focusedPaneId === null ? layout : { ...layout, focusedPaneId: null },
|
|
},
|
|
};
|
|
});
|
|
return token;
|
|
},
|
|
restorePaneFocus: (workspaceKey, token) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedToken = trimNonEmpty(token);
|
|
if (!normalizedWorkspaceKey || !normalizedToken) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const restoration = state.focusRestorationByWorkspace[normalizedWorkspaceKey];
|
|
if (!restoration?.tokens.includes(normalizedToken)) {
|
|
return state;
|
|
}
|
|
|
|
const nextTokens = restoration.tokens.filter((entry) => entry !== normalizedToken);
|
|
const { [normalizedWorkspaceKey]: _removed, ...remainingRestorations } =
|
|
state.focusRestorationByWorkspace;
|
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
|
|
|
if (layout.focusedPaneId !== null) {
|
|
return {
|
|
focusRestorationByWorkspace: remainingRestorations,
|
|
};
|
|
}
|
|
|
|
if (nextTokens.length > 0) {
|
|
return {
|
|
focusRestorationByWorkspace: {
|
|
...remainingRestorations,
|
|
[normalizedWorkspaceKey]: {
|
|
restorePaneId: restoration.restorePaneId,
|
|
tokens: nextTokens,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
const restorePane = findPaneById(layout.root, restoration.restorePaneId);
|
|
const restorePaneId = restorePane?.hidden === true ? null : (restorePane?.id ?? null);
|
|
if (!restorePaneId) {
|
|
return {
|
|
focusRestorationByWorkspace: remainingRestorations,
|
|
};
|
|
}
|
|
|
|
return {
|
|
focusRestorationByWorkspace: remainingRestorations,
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: {
|
|
...layout,
|
|
focusedPaneId: restorePaneId,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
resizeSplit: (workspaceKey, groupId, sizes) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedGroupId = trimNonEmpty(groupId);
|
|
if (!normalizedWorkspaceKey || !normalizedGroupId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => ({
|
|
splitSizesByWorkspace: {
|
|
...state.splitSizesByWorkspace,
|
|
[normalizedWorkspaceKey]: {
|
|
...state.splitSizesByWorkspace[normalizedWorkspaceKey],
|
|
[normalizedGroupId]: clampNormalizedSizes(sizes),
|
|
},
|
|
},
|
|
}));
|
|
},
|
|
resizeExplorerSidebar: (workspaceKey, width) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey || !Number.isFinite(width) || width <= 0) {
|
|
return;
|
|
}
|
|
|
|
set((state) => ({
|
|
explorerSidebarWidthByWorkspace: {
|
|
...state.explorerSidebarWidthByWorkspace,
|
|
[normalizedWorkspaceKey]: width,
|
|
},
|
|
}));
|
|
},
|
|
reorderTabsInPane: (workspaceKey, paneId, tabIds) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedPaneId = trimNonEmpty(paneId);
|
|
if (!normalizedWorkspaceKey || !normalizedPaneId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const nextLayout = reorderPaneTabsInLayout({
|
|
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
|
paneId: normalizedPaneId,
|
|
tabIds,
|
|
});
|
|
if (!nextLayout) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[normalizedWorkspaceKey]: nextLayout,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
unpinAgent: (workspaceKey, agentId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedAgentId = trimNonEmpty(agentId);
|
|
if (!normalizedWorkspaceKey || !normalizedAgentId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const currentPinnedAgentIds =
|
|
state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null;
|
|
if (!currentPinnedAgentIds?.has(normalizedAgentId)) {
|
|
return state;
|
|
}
|
|
|
|
if (currentPinnedAgentIds.size === 1) {
|
|
const nextPinnedAgentIdsByWorkspace = {
|
|
...state.pinnedAgentIdsByWorkspace,
|
|
};
|
|
delete nextPinnedAgentIdsByWorkspace[normalizedWorkspaceKey];
|
|
return {
|
|
pinnedAgentIdsByWorkspace: nextPinnedAgentIdsByWorkspace,
|
|
};
|
|
}
|
|
|
|
const nextPinnedAgentIds = new Set(currentPinnedAgentIds);
|
|
nextPinnedAgentIds.delete(normalizedAgentId);
|
|
|
|
return {
|
|
pinnedAgentIdsByWorkspace: {
|
|
...state.pinnedAgentIdsByWorkspace,
|
|
[normalizedWorkspaceKey]: nextPinnedAgentIds,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
hideAgent: (workspaceKey, agentId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedAgentId = trimNonEmpty(agentId);
|
|
if (!normalizedWorkspaceKey || !normalizedAgentId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const nextHiddenAgentIdsByWorkspace = addAgentIdToWorkspaceSet(
|
|
state.hiddenAgentIdsByWorkspace,
|
|
normalizedWorkspaceKey,
|
|
normalizedAgentId,
|
|
);
|
|
if (nextHiddenAgentIdsByWorkspace === state.hiddenAgentIdsByWorkspace) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
hiddenAgentIdsByWorkspace: nextHiddenAgentIdsByWorkspace,
|
|
};
|
|
});
|
|
},
|
|
unhideAgent: (workspaceKey, agentId) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
const normalizedAgentId = trimNonEmpty(agentId);
|
|
if (!normalizedWorkspaceKey || !normalizedAgentId) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const nextHiddenAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet(
|
|
state.hiddenAgentIdsByWorkspace,
|
|
normalizedWorkspaceKey,
|
|
normalizedAgentId,
|
|
);
|
|
if (nextHiddenAgentIdsByWorkspace === state.hiddenAgentIdsByWorkspace) {
|
|
return state;
|
|
}
|
|
|
|
return {
|
|
hiddenAgentIdsByWorkspace: nextHiddenAgentIdsByWorkspace,
|
|
};
|
|
});
|
|
},
|
|
purgeWorkspace: (workspaceKey) => {
|
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
|
if (!normalizedWorkspaceKey) {
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const hasAny =
|
|
normalizedWorkspaceKey in state.layoutByWorkspace ||
|
|
normalizedWorkspaceKey in state.splitSizesByWorkspace ||
|
|
normalizedWorkspaceKey in state.explorerSidebarWidthByWorkspace ||
|
|
normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace ||
|
|
normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace ||
|
|
normalizedWorkspaceKey in state.focusRestorationByWorkspace ||
|
|
normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace ||
|
|
normalizedWorkspaceKey in state.sidePaneIdByWorkspace ||
|
|
normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace ||
|
|
normalizedWorkspaceKey in state.sheetByWorkspace;
|
|
if (!hasAny) {
|
|
return state;
|
|
}
|
|
const {
|
|
[normalizedWorkspaceKey]: _autoOpened,
|
|
...pullRequestTabAutoOpenedByWorkspace
|
|
} = state.pullRequestTabAutoOpenedByWorkspace;
|
|
const { [normalizedWorkspaceKey]: _layout, ...layoutByWorkspace } =
|
|
state.layoutByWorkspace;
|
|
const { [normalizedWorkspaceKey]: _splits, ...splitSizesByWorkspace } =
|
|
state.splitSizesByWorkspace;
|
|
const {
|
|
[normalizedWorkspaceKey]: _explorerSidebarWidth,
|
|
...explorerSidebarWidthByWorkspace
|
|
} = state.explorerSidebarWidthByWorkspace;
|
|
const { [normalizedWorkspaceKey]: _pinned, ...pinnedAgentIdsByWorkspace } =
|
|
state.pinnedAgentIdsByWorkspace;
|
|
const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } =
|
|
state.hiddenAgentIdsByWorkspace;
|
|
const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } =
|
|
state.focusRestorationByWorkspace;
|
|
const {
|
|
[normalizedWorkspaceKey]: _explorerSidebarPane,
|
|
...explorerSidebarPaneIdByWorkspace
|
|
} = state.explorerSidebarPaneIdByWorkspace;
|
|
const { [normalizedWorkspaceKey]: _sidePane, ...sidePaneIdByWorkspace } =
|
|
state.sidePaneIdByWorkspace;
|
|
return {
|
|
...withoutSheet(state, normalizedWorkspaceKey),
|
|
pullRequestTabAutoOpenedByWorkspace,
|
|
layoutByWorkspace,
|
|
splitSizesByWorkspace,
|
|
explorerSidebarWidthByWorkspace,
|
|
pinnedAgentIdsByWorkspace,
|
|
hiddenAgentIdsByWorkspace,
|
|
focusRestorationByWorkspace,
|
|
explorerSidebarPaneIdByWorkspace,
|
|
sidePaneIdByWorkspace,
|
|
};
|
|
});
|
|
},
|
|
}),
|
|
{
|
|
name: "workspace-layout-state",
|
|
version: WORKSPACE_LAYOUT_PERSIST_VERSION,
|
|
storage: createValidatedPersistStorage(AsyncStorage, WorkspaceLayoutPersistedStateSchema),
|
|
migrate: (persistedState, version) =>
|
|
migrateWorkspaceLayoutPersistedState(persistedState, version, ids),
|
|
partialize: (state) => {
|
|
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
|
for (const key in state.layoutByWorkspace) {
|
|
// Strip ephemeral (commit diff) tabs before persisting so they are
|
|
// dropped on reload rather than restored pointing at a rebased SHA.
|
|
layoutByWorkspace[key] = stripEphemeralTabsFromLayout(
|
|
normalizeLayout(state.layoutByWorkspace[key]),
|
|
);
|
|
}
|
|
return {
|
|
layoutByWorkspace,
|
|
pinnedAgentIdsByWorkspace: Object.fromEntries(
|
|
Object.entries(state.pinnedAgentIdsByWorkspace).map(([key, agentIds]) => [
|
|
key,
|
|
Array.from(agentIds),
|
|
]),
|
|
),
|
|
splitSizesByWorkspace: state.splitSizesByWorkspace,
|
|
explorerSidebarWidthByWorkspace: state.explorerSidebarWidthByWorkspace,
|
|
explorerPaneIdByWorkspace: state.explorerSidebarPaneIdByWorkspace,
|
|
sidePaneIdByWorkspace: state.sidePaneIdByWorkspace,
|
|
pullRequestTabAutoOpenedByWorkspace: state.pullRequestTabAutoOpenedByWorkspace,
|
|
};
|
|
},
|
|
merge: (persistedState, currentState) => {
|
|
const result = WorkspaceLayoutPersistedStateSchema.safeParse(persistedState);
|
|
if (!result.success) {
|
|
return currentState;
|
|
}
|
|
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
|
const explorerSidebarPaneIdByWorkspace: Record<string, string | null> = {
|
|
...(result.data.explorerSidebarPaneIdByWorkspace ??
|
|
result.data.explorerPaneIdByWorkspace),
|
|
};
|
|
for (const [workspaceKey, persistedLayout] of Object.entries(
|
|
result.data.layoutByWorkspace,
|
|
)) {
|
|
const restoredLayout = restoreWorkspaceLayout(
|
|
stripEphemeralTabsFromLayout(persistedLayout),
|
|
resolveExplorerSidebarPaneId(
|
|
persistedLayout,
|
|
explorerSidebarPaneIdByWorkspace[workspaceKey],
|
|
),
|
|
ids.createNodeId,
|
|
);
|
|
const explorerSidebar = ensurePersistedExplorerSidebarPane({
|
|
layout: restoredLayout,
|
|
registeredPaneId: explorerSidebarPaneIdByWorkspace[workspaceKey],
|
|
ids,
|
|
});
|
|
layoutByWorkspace[workspaceKey] = explorerSidebar?.layout ?? restoredLayout;
|
|
if (explorerSidebar) {
|
|
explorerSidebarPaneIdByWorkspace[workspaceKey] = explorerSidebar.paneId;
|
|
}
|
|
}
|
|
return {
|
|
...currentState,
|
|
layoutByWorkspace,
|
|
pinnedAgentIdsByWorkspace: Object.fromEntries(
|
|
Object.entries(result.data.pinnedAgentIdsByWorkspace ?? {}).map(([key, agentIds]) => [
|
|
key,
|
|
new Set(agentIds),
|
|
]),
|
|
),
|
|
splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {},
|
|
explorerSidebarWidthByWorkspace:
|
|
result.data.explorerSidebarWidthByWorkspace ??
|
|
convertLegacyExplorerSidebarRatios(
|
|
result.data.explorerSidebarRatioByWorkspace ??
|
|
result.data.sidePanelRatioByWorkspace ??
|
|
{},
|
|
),
|
|
explorerSidebarPaneIdByWorkspace,
|
|
sidePaneIdByWorkspace: result.data.sidePaneIdByWorkspace ?? {},
|
|
pullRequestTabAutoOpenedByWorkspace:
|
|
result.data.pullRequestTabAutoOpenedByWorkspace ?? {},
|
|
};
|
|
},
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
export const useWorkspaceLayoutStore = createWorkspaceLayoutStore();
|
|
|
|
/**
|
|
* The agent tabs that exist right now, including sheet tabs, across every workspace of this host,
|
|
* independently of which workspace views are mounted. Timeline sync uses this to release a chat
|
|
* when its tab closes. It is not a subscription source: this layout is restored from disk at
|
|
* launch, so subscribing to it would resume every agent the user has ever opened.
|
|
*/
|
|
export function observeOpenWorkspaceAgentIds(
|
|
serverId: string,
|
|
listener: (agentIds: string[]) => void,
|
|
store = useWorkspaceLayoutStore,
|
|
): () => void {
|
|
let previous: string[] | undefined;
|
|
const publish = (state: Pick<WorkspaceLayoutStore, "layoutByWorkspace" | "sheetByWorkspace">) => {
|
|
const ids = new Set<string>();
|
|
const tabsByWorkspace = [
|
|
...Object.entries(state.layoutByWorkspace).map(
|
|
([workspaceKey, layout]) => [workspaceKey, collectAllTabs(layout.root)] as const,
|
|
),
|
|
...Object.entries(state.sheetByWorkspace).map(
|
|
([workspaceKey, sheet]) => [workspaceKey, [sheet.tab]] as const,
|
|
),
|
|
];
|
|
for (const [workspaceKey, tabs] of tabsByWorkspace) {
|
|
if (!workspaceKey.startsWith(`${serverId}:`)) continue;
|
|
for (const tab of tabs) {
|
|
if (tab.target.kind === "agent") ids.add(tab.target.agentId);
|
|
}
|
|
}
|
|
const next = [...ids].sort();
|
|
if (previous?.length === next.length && previous.every((id, index) => id === next[index]))
|
|
return;
|
|
previous = next;
|
|
listener(next);
|
|
};
|
|
const unsubscribe = store.subscribe((state, before) => {
|
|
if (
|
|
state.layoutByWorkspace !== before.layoutByWorkspace ||
|
|
state.sheetByWorkspace !== before.sheetByWorkspace
|
|
) {
|
|
publish(state);
|
|
}
|
|
});
|
|
publish(store.getState());
|
|
return unsubscribe;
|
|
}
|
|
|
|
export function useWorkspaceLayoutStoreHydrated(): boolean {
|
|
const [hasHydrated, setHasHydrated] = useState(() =>
|
|
useWorkspaceLayoutStore.persist.hasHydrated(),
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (useWorkspaceLayoutStore.persist.hasHydrated()) {
|
|
setHasHydrated(true);
|
|
return;
|
|
}
|
|
|
|
return useWorkspaceLayoutStore.persist.onFinishHydration(() => {
|
|
setHasHydrated(true);
|
|
});
|
|
}, []);
|
|
|
|
return hasHydrated;
|
|
}
|