475 lines
17 KiB
TypeScript
475 lines
17 KiB
TypeScript
import { create } from "zustand";
|
|
import { persist, createJSONStorage } from "zustand/middleware";
|
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
import { Platform } from "react-native";
|
|
import {
|
|
buildExplorerCheckoutKey,
|
|
coerceExplorerTabForCheckout,
|
|
isExplorerTab,
|
|
resolveExplorerTabForCheckout,
|
|
type ExplorerTab,
|
|
} from "./explorer-tab-memory";
|
|
export type { ExplorerTab } from "./explorer-tab-memory";
|
|
|
|
/**
|
|
* Mobile panel state machine.
|
|
*
|
|
* On mobile, exactly one panel can be visible at a time:
|
|
* - 'agent': Main agent view (no overlay panel)
|
|
* - 'agent-list': Agent list sidebar (left overlay)
|
|
* - 'file-explorer': File explorer sidebar (right overlay)
|
|
*
|
|
* This makes impossible states unrepresentable - you cannot have both
|
|
* sidebars open at the same time on mobile.
|
|
*/
|
|
type MobilePanelView = "agent" | "agent-list" | "file-explorer";
|
|
|
|
/**
|
|
* Desktop sidebar state.
|
|
*
|
|
* On desktop, sidebars are independent toggleable panels that don't overlay
|
|
* the main content - they sit alongside it. Both can be open simultaneously.
|
|
*/
|
|
interface DesktopSidebarState {
|
|
agentListOpen: boolean;
|
|
fileExplorerOpen: boolean;
|
|
}
|
|
|
|
export type SortOption = "name" | "modified" | "size";
|
|
export interface ExplorerCheckoutContext {
|
|
serverId: string;
|
|
cwd: string;
|
|
isGit: boolean;
|
|
}
|
|
|
|
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = Platform.OS === "web" ? 640 : 400;
|
|
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
|
|
// Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint.
|
|
export const MAX_EXPLORER_SIDEBAR_WIDTH = 2000;
|
|
|
|
export const DEFAULT_EXPLORER_FILES_SPLIT_RATIO = 0.38;
|
|
export const MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2;
|
|
export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8;
|
|
|
|
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
|
|
|
function logPanelTransition(
|
|
action: string,
|
|
details: Record<string, unknown>
|
|
): void {
|
|
if (!IS_DEV) {
|
|
return;
|
|
}
|
|
const stack =
|
|
new Error().stack
|
|
?.split("\n")
|
|
.slice(2, 8)
|
|
.join("\n") ?? "stack unavailable";
|
|
console.log(`[PanelStore] ${action}`, details, stack);
|
|
}
|
|
|
|
interface PanelState {
|
|
// Mobile: which panel is currently shown
|
|
mobileView: MobilePanelView;
|
|
|
|
// Desktop: independent sidebar toggles
|
|
desktop: DesktopSidebarState;
|
|
|
|
// File explorer settings (shared between mobile/desktop)
|
|
explorerTab: ExplorerTab;
|
|
explorerTabByCheckout: Record<string, ExplorerTab>;
|
|
activeExplorerCheckout: ExplorerCheckoutContext | null;
|
|
explorerWidth: number;
|
|
explorerSortOption: SortOption;
|
|
explorerFilesSplitRatio: number;
|
|
|
|
// Actions
|
|
openAgentList: () => void;
|
|
openFileExplorer: () => void;
|
|
closeFileExplorer: () => void;
|
|
closeToAgent: () => void;
|
|
toggleAgentList: () => void;
|
|
toggleFileExplorer: () => void;
|
|
|
|
// File explorer settings actions
|
|
setExplorerTab: (tab: ExplorerTab) => void;
|
|
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
|
|
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
|
|
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
|
|
setExplorerWidth: (width: number) => void;
|
|
setExplorerSortOption: (option: SortOption) => void;
|
|
setExplorerFilesSplitRatio: (ratio: number) => void;
|
|
}
|
|
|
|
function clampNumber(value: number, min: number, max: number): number {
|
|
if (!Number.isFinite(value)) {
|
|
return min;
|
|
}
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
function clampWidth(width: number): number {
|
|
return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH);
|
|
}
|
|
|
|
function clampExplorerFilesSplitRatio(ratio: number): number {
|
|
return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO);
|
|
}
|
|
|
|
function resolveExplorerTabFromActiveCheckout(state: PanelState): ExplorerTab | null {
|
|
if (!state.activeExplorerCheckout) {
|
|
return null;
|
|
}
|
|
return resolveExplorerTabForCheckout({
|
|
serverId: state.activeExplorerCheckout.serverId,
|
|
cwd: state.activeExplorerCheckout.cwd,
|
|
isGit: state.activeExplorerCheckout.isGit,
|
|
explorerTabByCheckout: state.explorerTabByCheckout,
|
|
});
|
|
}
|
|
|
|
const DEFAULT_DESKTOP_OPEN = Platform.OS === "web";
|
|
|
|
export const usePanelStore = create<PanelState>()(
|
|
persist(
|
|
(set) => ({
|
|
// Mobile always starts at agent view
|
|
mobileView: "agent",
|
|
|
|
// Desktop defaults based on platform
|
|
desktop: {
|
|
agentListOpen: DEFAULT_DESKTOP_OPEN,
|
|
fileExplorerOpen: DEFAULT_DESKTOP_OPEN,
|
|
},
|
|
|
|
// File explorer defaults
|
|
explorerTab: "changes",
|
|
explorerTabByCheckout: {},
|
|
activeExplorerCheckout: null,
|
|
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
|
explorerSortOption: "name",
|
|
explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
|
|
|
|
openAgentList: () =>
|
|
set((state) => {
|
|
const nextState = {
|
|
mobileView: "agent-list" as const,
|
|
desktop: { ...state.desktop, agentListOpen: true },
|
|
};
|
|
logPanelTransition("openAgentList", {
|
|
fromMobileView: state.mobileView,
|
|
toMobileView: nextState.mobileView,
|
|
fromDesktopAgentListOpen: state.desktop.agentListOpen,
|
|
toDesktopAgentListOpen: nextState.desktop.agentListOpen,
|
|
});
|
|
return nextState;
|
|
}),
|
|
|
|
openFileExplorer: () =>
|
|
set((state) => {
|
|
const resolvedTab = resolveExplorerTabFromActiveCheckout(state);
|
|
const nextMobileView: MobilePanelView = "file-explorer";
|
|
const nextDesktop = { ...state.desktop, fileExplorerOpen: true };
|
|
const nextState = {
|
|
mobileView: nextMobileView,
|
|
desktop: nextDesktop,
|
|
...(resolvedTab ? { explorerTab: resolvedTab } : {}),
|
|
};
|
|
logPanelTransition("openFileExplorer", {
|
|
fromMobileView: state.mobileView,
|
|
toMobileView: nextMobileView,
|
|
fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen,
|
|
toDesktopFileExplorerOpen: nextDesktop.fileExplorerOpen,
|
|
resolvedTab: resolvedTab ?? null,
|
|
activeCheckout: state.activeExplorerCheckout,
|
|
});
|
|
return nextState;
|
|
}),
|
|
closeFileExplorer: () =>
|
|
set((state) => {
|
|
const nextState = {
|
|
mobileView:
|
|
state.mobileView === "file-explorer" ? ("agent" as const) : state.mobileView,
|
|
desktop: {
|
|
...state.desktop,
|
|
fileExplorerOpen: false,
|
|
},
|
|
};
|
|
logPanelTransition("closeFileExplorer", {
|
|
fromMobileView: state.mobileView,
|
|
toMobileView: nextState.mobileView,
|
|
fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen,
|
|
toDesktopFileExplorerOpen: nextState.desktop.fileExplorerOpen,
|
|
});
|
|
return nextState;
|
|
}),
|
|
|
|
closeToAgent: () =>
|
|
set((state) => {
|
|
const nextState = {
|
|
mobileView: "agent" as const,
|
|
// On desktop, closing depends on which panel triggered it
|
|
// This is called when closing via gesture/backdrop, so we close the currently active mobile panel
|
|
desktop: {
|
|
agentListOpen:
|
|
state.mobileView === "agent-list" ? false : state.desktop.agentListOpen,
|
|
fileExplorerOpen:
|
|
state.mobileView === "file-explorer" ? false : state.desktop.fileExplorerOpen,
|
|
},
|
|
};
|
|
logPanelTransition("closeToAgent", {
|
|
fromMobileView: state.mobileView,
|
|
toMobileView: nextState.mobileView,
|
|
fromDesktopAgentListOpen: state.desktop.agentListOpen,
|
|
toDesktopAgentListOpen: nextState.desktop.agentListOpen,
|
|
fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen,
|
|
toDesktopFileExplorerOpen: nextState.desktop.fileExplorerOpen,
|
|
});
|
|
return nextState;
|
|
}),
|
|
|
|
toggleAgentList: () =>
|
|
set((state) => {
|
|
// Mobile: toggle between agent and agent-list
|
|
const newMobileView: MobilePanelView =
|
|
state.mobileView === "agent-list" ? "agent" : "agent-list";
|
|
const nextState = {
|
|
mobileView: newMobileView,
|
|
desktop: {
|
|
...state.desktop,
|
|
agentListOpen: !state.desktop.agentListOpen,
|
|
},
|
|
};
|
|
logPanelTransition("toggleAgentList", {
|
|
fromMobileView: state.mobileView,
|
|
toMobileView: nextState.mobileView,
|
|
fromDesktopAgentListOpen: state.desktop.agentListOpen,
|
|
toDesktopAgentListOpen: nextState.desktop.agentListOpen,
|
|
});
|
|
return nextState;
|
|
}),
|
|
|
|
toggleFileExplorer: () =>
|
|
set((state) => {
|
|
// Mobile: toggle between agent and file-explorer
|
|
const willOpenMobile = state.mobileView !== "file-explorer";
|
|
const willOpenDesktop = !state.desktop.fileExplorerOpen;
|
|
const nextMobileView: MobilePanelView = willOpenMobile
|
|
? "file-explorer"
|
|
: "agent";
|
|
const nextDesktop = {
|
|
...state.desktop,
|
|
fileExplorerOpen: willOpenDesktop,
|
|
};
|
|
const nextState: Pick<PanelState, "mobileView" | "desktop"> &
|
|
Partial<Pick<PanelState, "explorerTab">> = {
|
|
mobileView: nextMobileView,
|
|
desktop: nextDesktop,
|
|
};
|
|
let resolvedTab: ExplorerTab | null = null;
|
|
if (willOpenMobile || willOpenDesktop) {
|
|
resolvedTab = resolveExplorerTabFromActiveCheckout(state);
|
|
if (resolvedTab) {
|
|
nextState.explorerTab = resolvedTab;
|
|
}
|
|
}
|
|
logPanelTransition("toggleFileExplorer", {
|
|
fromMobileView: state.mobileView,
|
|
toMobileView: nextMobileView,
|
|
fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen,
|
|
toDesktopFileExplorerOpen: nextDesktop.fileExplorerOpen,
|
|
willOpenMobile,
|
|
willOpenDesktop,
|
|
resolvedTab: resolvedTab ?? null,
|
|
activeCheckout: state.activeExplorerCheckout,
|
|
});
|
|
return nextState;
|
|
}),
|
|
|
|
setExplorerTab: (tab) => set({ explorerTab: tab }),
|
|
setExplorerTabForCheckout: ({ serverId, cwd, isGit, tab }) =>
|
|
set((state) => {
|
|
const resolvedTab = coerceExplorerTabForCheckout(tab, isGit);
|
|
const key = buildExplorerCheckoutKey(serverId, cwd);
|
|
const nextState: Partial<PanelState> = { explorerTab: resolvedTab };
|
|
if (key) {
|
|
const current = state.explorerTabByCheckout[key];
|
|
if (current !== resolvedTab) {
|
|
nextState.explorerTabByCheckout = {
|
|
...state.explorerTabByCheckout,
|
|
[key]: resolvedTab,
|
|
};
|
|
}
|
|
}
|
|
return nextState;
|
|
}),
|
|
activateExplorerTabForCheckout: (checkout) =>
|
|
set((state) => ({
|
|
activeExplorerCheckout: checkout,
|
|
explorerTab: resolveExplorerTabForCheckout({
|
|
serverId: checkout.serverId,
|
|
cwd: checkout.cwd,
|
|
isGit: checkout.isGit,
|
|
explorerTabByCheckout: state.explorerTabByCheckout,
|
|
}),
|
|
})),
|
|
setActiveExplorerCheckout: (checkout) =>
|
|
set((state) => {
|
|
const current = state.activeExplorerCheckout;
|
|
if (
|
|
current?.serverId === checkout?.serverId &&
|
|
current?.cwd === checkout?.cwd &&
|
|
current?.isGit === checkout?.isGit
|
|
) {
|
|
return state;
|
|
}
|
|
return { activeExplorerCheckout: checkout };
|
|
}),
|
|
setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }),
|
|
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
|
|
setExplorerFilesSplitRatio: (ratio) =>
|
|
set({
|
|
explorerFilesSplitRatio: Number.isFinite(ratio)
|
|
? clampExplorerFilesSplitRatio(ratio)
|
|
: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
|
|
}),
|
|
}),
|
|
{
|
|
name: "panel-state",
|
|
version: 5,
|
|
storage: createJSONStorage(() => AsyncStorage),
|
|
migrate: (persistedState, version) => {
|
|
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
|
|
|
|
if (version < 2) {
|
|
if (
|
|
Platform.OS === "web" &&
|
|
typeof state.explorerWidth === "number" &&
|
|
state.explorerWidth === 400
|
|
) {
|
|
state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH;
|
|
}
|
|
|
|
if (typeof state.explorerFilesSplitRatio !== "number") {
|
|
state.explorerFilesSplitRatio = DEFAULT_EXPLORER_FILES_SPLIT_RATIO;
|
|
} else {
|
|
state.explorerFilesSplitRatio = clampExplorerFilesSplitRatio(
|
|
state.explorerFilesSplitRatio
|
|
);
|
|
}
|
|
}
|
|
|
|
if (version < 3) {
|
|
if (
|
|
Platform.OS === "web" &&
|
|
typeof state.explorerWidth === "number" &&
|
|
(state.explorerWidth === 400 || state.explorerWidth === 520)
|
|
) {
|
|
state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH;
|
|
}
|
|
}
|
|
|
|
if (!isExplorerTab(state.explorerTab)) {
|
|
state.explorerTab = "changes";
|
|
}
|
|
|
|
if (version < 4 || typeof state.explorerTabByCheckout !== "object" || !state.explorerTabByCheckout) {
|
|
state.explorerTabByCheckout = {};
|
|
} else {
|
|
const entries = Object.entries(state.explorerTabByCheckout as Record<string, unknown>);
|
|
const next: Record<string, ExplorerTab> = {};
|
|
for (const [key, value] of entries) {
|
|
if (!isExplorerTab(value)) {
|
|
continue;
|
|
}
|
|
next[key] = value;
|
|
}
|
|
state.explorerTabByCheckout = next;
|
|
}
|
|
|
|
state.activeExplorerCheckout = null;
|
|
|
|
return state as PanelState;
|
|
},
|
|
partialize: (state) => ({
|
|
mobileView: state.mobileView,
|
|
desktop: state.desktop,
|
|
explorerTab: state.explorerTab,
|
|
explorerTabByCheckout: state.explorerTabByCheckout,
|
|
explorerWidth: state.explorerWidth,
|
|
explorerSortOption: state.explorerSortOption,
|
|
explorerFilesSplitRatio: state.explorerFilesSplitRatio,
|
|
}),
|
|
}
|
|
)
|
|
);
|
|
|
|
/**
|
|
* Hook that provides platform-aware panel state.
|
|
*
|
|
* On mobile, uses the state machine (mobileView).
|
|
* On desktop, uses independent booleans (desktop.agentListOpen, desktop.fileExplorerOpen).
|
|
*
|
|
* @param isMobile - Whether the current breakpoint is mobile
|
|
*/
|
|
export function usePanelState(isMobile: boolean) {
|
|
const store = usePanelStore();
|
|
|
|
if (isMobile) {
|
|
return {
|
|
isAgentListOpen: store.mobileView === "agent-list",
|
|
isFileExplorerOpen: store.mobileView === "file-explorer",
|
|
openAgentList: store.openAgentList,
|
|
openFileExplorer: store.openFileExplorer,
|
|
closeAgentList: store.closeToAgent,
|
|
closeFileExplorer: store.closeToAgent,
|
|
toggleAgentList: store.toggleAgentList,
|
|
toggleFileExplorer: store.toggleFileExplorer,
|
|
// Explorer settings
|
|
explorerTab: store.explorerTab,
|
|
explorerTabByCheckout: store.explorerTabByCheckout,
|
|
explorerWidth: store.explorerWidth,
|
|
explorerSortOption: store.explorerSortOption,
|
|
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
|
|
setExplorerTab: store.setExplorerTab,
|
|
setExplorerTabForCheckout: store.setExplorerTabForCheckout,
|
|
activateExplorerTabForCheckout: store.activateExplorerTabForCheckout,
|
|
setActiveExplorerCheckout: store.setActiveExplorerCheckout,
|
|
setExplorerWidth: store.setExplorerWidth,
|
|
setExplorerSortOption: store.setExplorerSortOption,
|
|
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,
|
|
};
|
|
}
|
|
|
|
// Desktop: independent toggles
|
|
return {
|
|
isAgentListOpen: store.desktop.agentListOpen,
|
|
isFileExplorerOpen: store.desktop.fileExplorerOpen,
|
|
openAgentList: store.openAgentList,
|
|
openFileExplorer: store.openFileExplorer,
|
|
closeAgentList: () =>
|
|
usePanelStore.setState((state) => ({
|
|
desktop: { ...state.desktop, agentListOpen: false },
|
|
})),
|
|
closeFileExplorer: () =>
|
|
usePanelStore.setState((state) => ({
|
|
desktop: { ...state.desktop, fileExplorerOpen: false },
|
|
})),
|
|
toggleAgentList: store.toggleAgentList,
|
|
toggleFileExplorer: store.toggleFileExplorer,
|
|
// Explorer settings
|
|
explorerTab: store.explorerTab,
|
|
explorerTabByCheckout: store.explorerTabByCheckout,
|
|
explorerWidth: store.explorerWidth,
|
|
explorerSortOption: store.explorerSortOption,
|
|
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
|
|
setExplorerTab: store.setExplorerTab,
|
|
setExplorerTabForCheckout: store.setExplorerTabForCheckout,
|
|
activateExplorerTabForCheckout: store.activateExplorerTabForCheckout,
|
|
setActiveExplorerCheckout: store.setActiveExplorerCheckout,
|
|
setExplorerWidth: store.setExplorerWidth,
|
|
setExplorerSortOption: store.setExplorerSortOption,
|
|
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,
|
|
};
|
|
}
|