Files
paseo/packages/app/src/stores/panel-store/state.ts
T
Mohamed Boudra 75adf53893 feat(app): polish workspace tabs and changes
Size tabs from rendered labels and switch to horizontal overflow once minimum click targets are reached, keeping the add-tab action adjacent until it must remain fixed.

Make Changes an always-expanded document with an optional desktop navigation tree, keep Git actions in their platform-owned headers, remove pinned and inline split shortcuts, and place review tabs beside Changes.
2026-08-17 21:14:37 +02:00

318 lines
9.5 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_TREE_RAIL_WIDTH = 320;
export const MIN_TREE_RAIL_WIDTH = 200;
export const MAX_TREE_RAIL_WIDTH = 600;
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 clampTreeRailWidth(width: number): number {
return clampNumber(width, MIN_TREE_RAIL_WIDTH, MAX_TREE_RAIL_WIDTH);
}
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(),
// Accepted only so migration can discard the former per-file diff expansion state.
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(),
treeRailWidth: 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;
}
}
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;
}
}
function migrateTreeRailWidth(state: MigratablePanelState, version: number): void {
if (version < 13 || typeof state.treeRailWidth !== "number") {
delete state.explorerFilesSplitRatio;
state.treeRailWidth = DEFAULT_TREE_RAIL_WIDTH;
return;
}
state.treeRailWidth = clampTreeRailWidth(state.treeRailWidth);
}
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 = {};
}
delete state.diffExpandedPathsByWorkspace;
if (
version < 12 ||
typeof state.diffCollapsedFoldersByWorkspace !== "object" ||
!state.diffCollapsedFoldersByWorkspace
) {
state.diffCollapsedFoldersByWorkspace = {};
}
if (typeof state.explorerShowHiddenFiles !== "boolean") {
state.explorerShowHiddenFiles = true;
}
migrateTreeRailWidth(state, version);
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 };