mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 01:14:52 +00:00
* fix(explorer): prevent persisted trees from crashing startup Persisted expansion restored every directory and recursively flattened the resulting tree during render. Limit automatic restoration to five rendered directory levels and flatten iteratively so deep or broad workspaces cannot exhaust the JavaScript stack. * fix(explorer): preserve expansion changes during restore Reconcile completed restoration against the current expansion set atomically so folder interactions made while directory requests are in flight are not overwritten. * fix(explorer): show hidden files before restoration * fix(explorer): apply directory toggles atomically
380 lines
14 KiB
TypeScript
380 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 type ExpandedPathsUpdate = string[] | ((currentPaths: string[]) => string[]);
|
|
|
|
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: ExpandedPathsUpdate) => 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) => {
|
|
const currentPaths = state.expandedPathsByWorkspace[workspaceKey] ?? ["."];
|
|
const nextPaths = typeof paths === "function" ? paths(currentPaths) : paths;
|
|
return {
|
|
expandedPathsByWorkspace: {
|
|
...state.expandedPathsByWorkspace,
|
|
[workspaceKey]: nextPaths,
|
|
},
|
|
};
|
|
}),
|
|
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,
|
|
};
|
|
}
|