Files
paseo/packages/app/src/stores/panel-store/index.ts
T
Mohamed BoudraandGitHub 6af1379357 Stop mobile sidebars getting stuck out of sync (#1953)
* fix(app): keep mobile sidebars synced with store

Gesture-owned skip flags could swallow the next store sync after a no-op or overlapping gesture, leaving React state and Reanimated shared values split. Always reconcile both mobile sidebars from the panel store after gesture animations.

* test(app): keep sidebar sync coverage pure

* fix(app): keep sidebar close transitions idempotent

* fix(app): keep mobile sidebars stable through interruptions

Independent React and worklet state let stale gesture or animation callbacks
overwrite newer panel commands. Use one revisioned target and one transient
motion runtime so outdated callbacks cannot restore an earlier panel.
2026-07-09 23:08:56 +02:00

371 lines
14 KiB
TypeScript

import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
buildExplorerCheckoutKey,
coerceExplorerTabForCheckout,
resolveExplorerTabForCheckout,
type ExplorerTab,
} from "../explorer-tab-memory";
import { type ExplorerCheckoutContext } from "../explorer-checkout-context";
import {
buildOpenFileExplorerPatch,
buildToggleFileExplorerPatch,
clampExplorerFilesSplitRatio,
clampExplorerWidth,
clampSidebarWidth,
DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
DEFAULT_EXPLORER_SIDEBAR_WIDTH,
DEFAULT_SIDEBAR_WIDTH,
MAX_EXPLORER_FILES_SPLIT_RATIO,
MAX_EXPLORER_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH,
MIN_EXPLORER_FILES_SPLIT_RATIO,
MIN_EXPLORER_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
migratePanelState,
selectIsAgentListOpen,
selectIsFileExplorerOpen,
setMobilePanelTarget,
selectPanelVisibility,
type DesktopSidebarState,
type ExplorerPanelIntent,
type MobilePanelView,
type MobilePanelSelection,
type PanelLayoutInput,
type PanelVisibilityState,
type SortOption,
} from "./state";
import { isWeb } from "@/constants/platform";
export type { ExplorerTab } from "../explorer-tab-memory";
export type { ExplorerCheckoutContext } from "../explorer-checkout-context";
export type {
DesktopSidebarState,
ExplorerPanelIntent,
MobilePanelView,
MobilePanelSelection,
PanelLayoutInput,
PanelVisibilityState,
SortOption,
} from "./state";
export {
DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
DEFAULT_EXPLORER_SIDEBAR_WIDTH,
DEFAULT_SIDEBAR_WIDTH,
MAX_EXPLORER_FILES_SPLIT_RATIO,
MAX_EXPLORER_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH,
MIN_EXPLORER_FILES_SPLIT_RATIO,
MIN_EXPLORER_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
selectIsAgentListOpen,
selectIsFileExplorerOpen,
selectPanelVisibility,
};
export interface PanelState {
// Mobile: React's durable target plus the generation that owns it.
mobilePanel: MobilePanelSelection;
// Desktop: independent sidebar toggles
desktop: DesktopSidebarState;
// File explorer settings (shared between mobile/desktop)
explorerTab: ExplorerTab;
explorerTabByCheckout: Record<string, ExplorerTab>;
expandedPathsByWorkspace: Record<string, string[]>;
diffExpandedPathsByWorkspace: Record<string, string[]>;
// Changes-view folder tree. Inverted semantics vs the fields above:
// this stores COLLAPSED directory paths (empty = all folders expanded), keyed
// by full uncompressed dir path, so folders default to expanded and new
// folders stay expanded as the diff changes.
diffCollapsedFoldersByWorkspace: Record<string, string[]>;
sidebarWidth: number;
explorerWidth: number;
explorerSortOption: SortOption;
explorerShowHiddenFiles: boolean;
explorerFilesSplitRatio: number;
// Actions
toggleFocusMode: () => void;
showMobileAgent: () => void;
showMobileAgentList: () => void;
toggleMobileAgentList: () => void;
openDesktopAgentList: () => void;
closeDesktopAgentList: () => void;
toggleDesktopAgentList: () => void;
closeDesktopFileExplorer: () => void;
openAgentListForLayout: (input: PanelLayoutInput) => void;
closeAgentListForLayout: (input: PanelLayoutInput) => void;
toggleAgentListForLayout: (input: PanelLayoutInput) => void;
openFileExplorerForCheckout: (input: ExplorerPanelIntent) => void;
toggleFileExplorerForCheckout: (input: ExplorerPanelIntent) => void;
// File explorer settings actions
setExplorerTab: (tab: ExplorerTab) => void;
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
setExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
setDiffExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
setDiffCollapsedFoldersForWorkspace: (workspaceKey: string, dirPaths: string[]) => void;
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
setSidebarWidth: (width: number) => void;
setExplorerWidth: (width: number) => void;
setExplorerSortOption: (option: SortOption) => void;
toggleExplorerShowHiddenFiles: () => void;
setExplorerFilesSplitRatio: (ratio: number) => void;
}
const DEFAULT_DESKTOP_OPEN = isWeb;
function setMobilePanelTargetPatch(
state: PanelState,
target: MobilePanelView,
): PanelState | Pick<PanelState, "mobilePanel"> {
const mobilePanel = setMobilePanelTarget(state.mobilePanel, target);
return mobilePanel === state.mobilePanel ? state : { mobilePanel };
}
export const usePanelStore = create<PanelState>()(
persist(
(set) => ({
// Mobile always starts at agent view
mobilePanel: { target: "agent", revision: 0 },
// Desktop defaults based on platform
desktop: {
agentListOpen: DEFAULT_DESKTOP_OPEN,
fileExplorerOpen: false,
focusModeEnabled: false,
},
// File explorer defaults
explorerTab: "changes",
explorerTabByCheckout: {},
expandedPathsByWorkspace: {},
diffExpandedPathsByWorkspace: {},
diffCollapsedFoldersByWorkspace: {},
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
explorerSortOption: "name",
explorerShowHiddenFiles: true,
explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
toggleFocusMode: () =>
set((state) => ({
desktop: { ...state.desktop, focusModeEnabled: !state.desktop.focusModeEnabled },
})),
showMobileAgent: () => set((state) => setMobilePanelTargetPatch(state, "agent")),
showMobileAgentList: () => set((state) => setMobilePanelTargetPatch(state, "agent-list")),
toggleMobileAgentList: () =>
set((state) =>
setMobilePanelTargetPatch(
state,
state.mobilePanel.target === "agent-list" ? "agent" : "agent-list",
),
),
openDesktopAgentList: () =>
set((state) => {
if (state.desktop.agentListOpen) {
return state;
}
return { desktop: { ...state.desktop, agentListOpen: true } };
}),
closeDesktopAgentList: () =>
set((state) => {
if (!state.desktop.agentListOpen) {
return state;
}
return { desktop: { ...state.desktop, agentListOpen: false } };
}),
toggleDesktopAgentList: () =>
set((state) => ({
desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen },
})),
closeDesktopFileExplorer: () =>
set((state) => {
if (!state.desktop.fileExplorerOpen) {
return state;
}
return { desktop: { ...state.desktop, fileExplorerOpen: false } };
}),
openAgentListForLayout: ({ isCompact }) =>
set((state) => {
if (isCompact) {
return setMobilePanelTargetPatch(state, "agent-list");
}
return state.desktop.agentListOpen
? state
: { desktop: { ...state.desktop, agentListOpen: true } };
}),
closeAgentListForLayout: ({ isCompact }) =>
set((state) => {
if (isCompact) {
return setMobilePanelTargetPatch(state, "agent");
}
return state.desktop.agentListOpen
? { desktop: { ...state.desktop, agentListOpen: false } }
: state;
}),
toggleAgentListForLayout: ({ isCompact }) =>
set((state) => {
if (isCompact) {
return setMobilePanelTargetPatch(
state,
state.mobilePanel.target === "agent-list" ? "agent" : "agent-list",
);
}
return {
desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen },
};
}),
openFileExplorerForCheckout: (input) =>
set((state) => buildOpenFileExplorerPatch(state, input)),
toggleFileExplorerForCheckout: (input) =>
set((state) => buildToggleFileExplorerPatch(state, input)),
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;
}),
setExpandedPathsForWorkspace: (workspaceKey, paths) =>
set((state) => ({
expandedPathsByWorkspace: { ...state.expandedPathsByWorkspace, [workspaceKey]: paths },
})),
setDiffExpandedPathsForWorkspace: (workspaceKey, paths) =>
set((state) => ({
diffExpandedPathsByWorkspace: {
...state.diffExpandedPathsByWorkspace,
[workspaceKey]: paths,
},
})),
setDiffCollapsedFoldersForWorkspace: (workspaceKey, dirPaths) =>
set((state) => ({
diffCollapsedFoldersByWorkspace: {
...state.diffCollapsedFoldersByWorkspace,
[workspaceKey]: dirPaths,
},
})),
activateExplorerTabForCheckout: (checkout) =>
set((state) => ({
explorerTab: resolveExplorerTabForCheckout({
serverId: checkout.serverId,
cwd: checkout.cwd,
isGit: checkout.isGit,
explorerTabByCheckout: state.explorerTabByCheckout,
}),
})),
setSidebarWidth: (width) => set({ sidebarWidth: clampSidebarWidth(width) }),
setExplorerWidth: (width) => set({ explorerWidth: clampExplorerWidth(width) }),
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
toggleExplorerShowHiddenFiles: () =>
set((state) => ({ explorerShowHiddenFiles: !state.explorerShowHiddenFiles })),
setExplorerFilesSplitRatio: (ratio) =>
set({
explorerFilesSplitRatio: Number.isFinite(ratio)
? clampExplorerFilesSplitRatio(ratio)
: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
}),
}),
{
name: "panel-state",
version: 12,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState, version) =>
migratePanelState(persistedState, version, { isWeb }) as unknown as PanelState,
partialize: (state) => ({
desktop: state.desktop,
explorerTab: state.explorerTab,
explorerTabByCheckout: state.explorerTabByCheckout,
expandedPathsByWorkspace: state.expandedPathsByWorkspace,
diffExpandedPathsByWorkspace: state.diffExpandedPathsByWorkspace,
diffCollapsedFoldersByWorkspace: state.diffCollapsedFoldersByWorkspace,
sidebarWidth: state.sidebarWidth,
explorerWidth: state.explorerWidth,
explorerSortOption: state.explorerSortOption,
explorerShowHiddenFiles: state.explorerShowHiddenFiles,
explorerFilesSplitRatio: state.explorerFilesSplitRatio,
}),
},
),
);
/**
* Hook that provides platform-aware panel state.
*
* On mobile, uses the revisioned mobile panel target.
* On desktop, uses independent booleans (desktop.agentListOpen, desktop.fileExplorerOpen).
*
* @param isMobile - Whether the current breakpoint is mobile
*/
export function usePanelState(isMobile: boolean) {
const isAgentListOpen = usePanelStore((state) =>
selectIsAgentListOpen(state, { isCompact: isMobile }),
);
const isFileExplorerOpen = usePanelStore((state) =>
selectIsFileExplorerOpen(state, { isCompact: isMobile }),
);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const openAgentListForLayout = usePanelStore((state) => state.openAgentListForLayout);
const closeAgentListForLayout = usePanelStore((state) => state.closeAgentListForLayout);
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const explorerTab = usePanelStore((state) => state.explorerTab);
const explorerTabByCheckout = usePanelStore((state) => state.explorerTabByCheckout);
const explorerWidth = usePanelStore((state) => state.explorerWidth);
const explorerSortOption = usePanelStore((state) => state.explorerSortOption);
const explorerFilesSplitRatio = usePanelStore((state) => state.explorerFilesSplitRatio);
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const activateExplorerTabForCheckout = usePanelStore(
(state) => state.activateExplorerTabForCheckout,
);
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
const setExplorerSortOption = usePanelStore((state) => state.setExplorerSortOption);
const setExplorerFilesSplitRatio = usePanelStore((state) => state.setExplorerFilesSplitRatio);
return {
isAgentListOpen,
isFileExplorerOpen,
openAgentList: () => openAgentListForLayout({ isCompact: isMobile }),
closeAgentList: () => closeAgentListForLayout({ isCompact: isMobile }),
closeFileExplorer: isMobile ? showMobileAgent : closeDesktopFileExplorer,
toggleAgentList: () => toggleAgentListForLayout({ isCompact: isMobile }),
explorerTab,
explorerTabByCheckout,
explorerWidth,
explorerSortOption,
explorerFilesSplitRatio,
setExplorerTab,
setExplorerTabForCheckout,
activateExplorerTabForCheckout,
setExplorerWidth,
setExplorerSortOption,
setExplorerFilesSplitRatio,
};
}