Files
paseo/packages/app/src/stores/panel-store/state.ts
T
Mohamed BoudraandGitHub 36312352db fix(app): validate persisted client state (#3289)
* fix(app): validate persisted client state

Persisted replica data could satisfy TypeScript through an assertion while missing fields required by renderers. Treat every AsyncStorage value as untrusted input, clear values that fail strict schemas, and version the replica DTO so incompatible caches cannot hydrate.

* fix(app): preserve validated legacy state

* fix(app): preserve validated preferences

Preserve supported legacy location fields and every shipped theme while keeping unknown persisted fields rejected. Align browser test seeds with the current preference shape so strict validation exercises the intended provider.

* fix(app): migrate legacy agent preferences

Released clients stored host/location fields and per-provider thinkingOptionId together. Migrate those exact strict bytes into current preferences without admitting unknown fields.

* fix(app): preserve drafts with legacy reviews

Accept the exact strict shape released for workspace review attachments so migration can discard that obsolete attachment without clearing every persisted draft.
2026-08-13 12:02:39 +02:00

317 lines
9.4 KiB
TypeScript

import {
buildExplorerCheckoutKey,
isExplorerTab,
resolveExplorerTabForCheckout,
type ExplorerTab,
} from "../explorer-tab-memory";
import { type ExplorerCheckoutContext } from "../explorer-checkout-context";
import { z } from "zod";
export type MobilePanelView = "agent" | "agent-list" | "file-explorer";
export interface MobilePanelSelection {
target: MobilePanelView;
revision: number;
}
export interface DesktopSidebarState {
agentListOpen: boolean;
fileExplorerOpen: boolean;
focusModeEnabled: boolean;
}
export type SortOption = "name" | "modified" | "size";
export const DEFAULT_SIDEBAR_WIDTH = 320;
export const MIN_SIDEBAR_WIDTH = 200;
export const MAX_SIDEBAR_WIDTH = 600;
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 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;
export interface PanelVisibilityState {
isAgentListOpen: boolean;
isFileExplorerOpen: boolean;
}
export interface PanelLayoutInput {
isCompact: boolean;
}
export interface ExplorerPanelIntent extends PanelLayoutInput {
checkout: ExplorerCheckoutContext;
}
export interface PanelCoreState {
mobilePanel: MobilePanelSelection;
desktop: DesktopSidebarState;
explorerTab: ExplorerTab;
explorerTabByCheckout: Record<string, ExplorerTab>;
}
function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) {
return min;
}
return Math.max(min, Math.min(max, value));
}
export function clampSidebarWidth(width: number): number {
return clampNumber(width, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH);
}
export function clampExplorerWidth(width: number): number {
return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH);
}
export function clampExplorerFilesSplitRatio(ratio: number): number {
return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO);
}
export function selectPanelVisibility(
state: PanelCoreState,
input: PanelLayoutInput,
): PanelVisibilityState {
if (input.isCompact) {
return {
isAgentListOpen: state.mobilePanel.target === "agent-list",
isFileExplorerOpen: state.mobilePanel.target === "file-explorer",
};
}
return {
isAgentListOpen: state.desktop.agentListOpen,
isFileExplorerOpen: state.desktop.fileExplorerOpen,
};
}
export function selectIsAgentListOpen(state: PanelCoreState, input: PanelLayoutInput): boolean {
return selectPanelVisibility(state, input).isAgentListOpen;
}
export function selectIsFileExplorerOpen(state: PanelCoreState, input: PanelLayoutInput): boolean {
return selectPanelVisibility(state, input).isFileExplorerOpen;
}
export function setMobilePanelTarget(
selection: MobilePanelSelection,
target: MobilePanelView,
): MobilePanelSelection {
if (selection.target === target) {
return selection;
}
return { target, revision: selection.revision + 1 };
}
function resolveExplorerTabFromCheckout(
state: PanelCoreState,
checkout: ExplorerCheckoutContext,
): ExplorerTab {
return resolveExplorerTabForCheckout({
serverId: checkout.serverId,
cwd: checkout.cwd,
isGit: checkout.isGit,
explorerTabByCheckout: state.explorerTabByCheckout,
});
}
export interface OpenFileExplorerPatch {
mobilePanel?: MobilePanelSelection;
desktop?: DesktopSidebarState;
explorerTab: ExplorerTab;
}
export function buildOpenFileExplorerPatch(
state: PanelCoreState,
input: ExplorerPanelIntent,
): OpenFileExplorerPatch {
const resolvedTab = resolveExplorerTabFromCheckout(state, input.checkout);
if (input.isCompact) {
return {
mobilePanel: setMobilePanelTarget(state.mobilePanel, "file-explorer"),
explorerTab: resolvedTab,
};
}
return {
desktop: { ...state.desktop, fileExplorerOpen: true },
explorerTab: resolvedTab,
};
}
export type ToggleFileExplorerPatch =
| OpenFileExplorerPatch
| { mobilePanel: MobilePanelSelection }
| { desktop: DesktopSidebarState };
export function buildToggleFileExplorerPatch(
state: PanelCoreState,
input: ExplorerPanelIntent,
): ToggleFileExplorerPatch {
const isOpen = selectIsFileExplorerOpen(state, input);
if (!isOpen) {
return buildOpenFileExplorerPatch(state, input);
}
if (input.isCompact) {
return { mobilePanel: setMobilePanelTarget(state.mobilePanel, "agent") };
}
return { desktop: { ...state.desktop, fileExplorerOpen: false } };
}
const ExplorerTabSchema = z.enum(["changes", "files", "pr"]);
const DesktopSidebarStorageSchema = z.strictObject({
agentListOpen: z.boolean().optional(),
fileExplorerOpen: z.boolean().optional(),
focusModeEnabled: z.boolean().optional(),
zoomed: z.boolean().optional(),
focused: z.boolean().optional(),
});
export const PanelPersistedStateSchema = z.strictObject({
mobileView: z.enum(["agent", "agent-list", "file-explorer"]).optional(),
mobilePanel: z
.strictObject({
target: z.enum(["agent", "agent-list", "file-explorer"]),
revision: z.number().int().nonnegative(),
})
.optional(),
desktop: DesktopSidebarStorageSchema.optional(),
explorerTab: ExplorerTabSchema.optional(),
explorerTabByCheckout: z.record(z.string(), ExplorerTabSchema).optional(),
expandedPathsByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
diffExpandedPathsByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
diffCollapsedFoldersByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
sidebarWidth: z.number().optional(),
explorerWidth: z.number().optional(),
explorerSortOption: z.enum(["name", "modified", "size"]).optional(),
explorerShowHiddenFiles: z.boolean().optional(),
explorerFilesSplitRatio: z.number().optional(),
});
type MigratablePanelState = z.infer<typeof PanelPersistedStateSchema>;
function migratePanelV2Explorer(state: MigratablePanelState, isWeb: boolean): void {
if (isWeb && 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);
}
}
function migratePanelV3Explorer(state: MigratablePanelState, isWeb: boolean): void {
if (
isWeb &&
typeof state.explorerWidth === "number" &&
(state.explorerWidth === 400 || state.explorerWidth === 520)
) {
state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH;
}
}
function migratePanelExplorerTabByCheckout(state: MigratablePanelState, version: number): void {
if (
version < 4 ||
typeof state.explorerTabByCheckout !== "object" ||
!state.explorerTabByCheckout
) {
state.explorerTabByCheckout = {};
return;
}
const entries = Object.entries(state.explorerTabByCheckout);
const next: Record<string, ExplorerTab> = {};
for (const [key, value] of entries) {
if (!isExplorerTab(value)) {
continue;
}
next[key] = value;
}
state.explorerTabByCheckout = next;
}
function migratePanelDesktopFocusMode(state: MigratablePanelState): void {
const desktop = state.desktop;
if (!desktop) {
return;
}
if ("zoomed" in desktop) {
desktop.focusModeEnabled = desktop.zoomed;
delete desktop.zoomed;
}
if ("focused" in desktop) {
desktop.focusModeEnabled = desktop.focused;
delete desktop.focused;
}
if (typeof desktop.focusModeEnabled !== "boolean") {
desktop.focusModeEnabled = false;
}
}
export function migratePanelState(
persistedState: unknown,
version: number,
options: { isWeb: boolean },
): MigratablePanelState {
const result = PanelPersistedStateSchema.safeParse(persistedState);
const state: MigratablePanelState = result.success ? result.data : {};
const { isWeb } = options;
if (version < 2) {
migratePanelV2Explorer(state, isWeb);
}
if (version < 3) {
migratePanelV3Explorer(state, isWeb);
}
if (!isExplorerTab(state.explorerTab)) {
state.explorerTab = "changes";
}
migratePanelExplorerTabByCheckout(state, version);
if (version < 8) {
migratePanelDesktopFocusMode(state);
}
if (version < 6 || typeof state.sidebarWidth !== "number") {
state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
}
if (
version < 9 ||
typeof state.expandedPathsByWorkspace !== "object" ||
!state.expandedPathsByWorkspace
) {
state.expandedPathsByWorkspace = {};
}
if (
version < 10 ||
typeof state.diffExpandedPathsByWorkspace !== "object" ||
!state.diffExpandedPathsByWorkspace
) {
state.diffExpandedPathsByWorkspace = {};
}
if (
version < 12 ||
typeof state.diffCollapsedFoldersByWorkspace !== "object" ||
!state.diffCollapsedFoldersByWorkspace
) {
state.diffCollapsedFoldersByWorkspace = {};
}
if (typeof state.explorerShowHiddenFiles !== "boolean") {
state.explorerShowHiddenFiles = true;
}
if (version < 12) {
// Compact panel position is transient UI state. Cold starts always begin
// at content, regardless of what an older version persisted.
delete state.mobileView;
delete state.mobilePanel;
}
return state;
}
export { buildExplorerCheckoutKey, resolveExplorerTabForCheckout };
export type { ExplorerTab, ExplorerCheckoutContext };