fix(app): retire the docked explorer sidebar
Upgrading to 0.5.0-beta.1 left the old docked explorer sidebar showing next to the new explorer pane, with no way to dismiss it. The sidebar rendered off the persisted `desktop.fileExplorerOpen` flag. #3287 rerouted the explorer toggle to the workspace-layout explorer pane, so nothing on a wide layout ever wrote that flag false again, and no migration cleared it. Assistant directory links and review attachments could still set it. On Windows and Linux the sidebar's close button was suppressed because the OS window controls own the top-right corner, so there was no affordance at all. The explorer is now one surface picked from two facts — is the layout compact, and can the platform render split panes: compact -> slide-over overlay (panel store) non-compact + splits -> dedicated right pane (layout store) non-compact, no splits -> ordinary tabs in the focused pane `workspace-tabs/explorer-surface.ts` owns that choice and every entry point routes through it, so the toggle, Ctrl+E, Ctrl+., directory links, review attachments and side file opens can no longer disagree about where the explorer lives. Pane splits stay gated on `isWeb`: they depend on dnd-kit and DOM-backed accessibility helpers. Native tablets are non-compact without splits, so they get explorer tabs rather than a second pane — deliberately trading side-by-side agent and Changes for a surface that only renders what the single-column tab row can actually reach. Ctrl+. is handled by the workspace screen rather than the app shell. The pathname alone cannot identify the active workspace: desktop cold-starts at "/" and restores the remembered workspace from route params, so a key derived from the pathname is null exactly when a workspace is mounted, and the explorer reads as permanently closed. `desktop.fileExplorerOpen` and `explorerWidth` are dropped by a migration to panel-state v15 but stay declared in the persisted schema. That schema is a strict object and the validated storage deletes the whole entry when parsing fails, so removing the keys outright would wipe every upgrading user's sidebar width, expanded paths and diff-collapse state.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
||||
import {
|
||||
navigateToLastWorkspace,
|
||||
navigateToWorkspace,
|
||||
@@ -176,6 +177,21 @@ describe("workspace navigation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Desktop cold-starts at "/" (packages/desktop/src/main.ts) and restores the
|
||||
// remembered workspace, so a workspace is mounted while the pathname carries
|
||||
// no workspace at all. Anything that identifies the active workspace from the
|
||||
// pathname alone silently gets nothing there — and reports that workspace's
|
||||
// panes as closed.
|
||||
it("resolves a workspace the pathname alone cannot identify", () => {
|
||||
const params = { serverId: "server-1", workspaceId: "workspace-a" };
|
||||
|
||||
expect(parseHostWorkspaceRouteFromPathname("/")).toBeNull();
|
||||
expect(parseActiveWorkspaceSelection({ pathname: "/", params })).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores stale workspace route params while an app-wide route is active", () => {
|
||||
const selection = parseActiveWorkspaceSelection({
|
||||
pathname: "/settings/general",
|
||||
|
||||
@@ -12,29 +12,22 @@ import {
|
||||
buildOpenFileExplorerPatch,
|
||||
buildToggleFileExplorerPatch,
|
||||
clampTreeRailWidth,
|
||||
clampExplorerWidth,
|
||||
clampSidebarWidth,
|
||||
DEFAULT_TREE_RAIL_WIDTH,
|
||||
DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||
DEFAULT_SIDEBAR_WIDTH,
|
||||
MAX_TREE_RAIL_WIDTH,
|
||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
MIN_TREE_RAIL_WIDTH,
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
migratePanelState,
|
||||
PanelPersistedStateSchema,
|
||||
selectIsAgentListOpen,
|
||||
selectIsFileExplorerOpen,
|
||||
selectIsCompactFileExplorerOpen,
|
||||
setMobilePanelTarget,
|
||||
selectPanelVisibility,
|
||||
type DesktopSidebarState,
|
||||
type ExplorerPanelIntent,
|
||||
type MobilePanelView,
|
||||
type MobilePanelSelection,
|
||||
type PanelLayoutInput,
|
||||
type PanelVisibilityState,
|
||||
type SortOption,
|
||||
} from "./state";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -43,27 +36,21 @@ 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_TREE_RAIL_WIDTH,
|
||||
DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||
DEFAULT_SIDEBAR_WIDTH,
|
||||
MAX_TREE_RAIL_WIDTH,
|
||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
MIN_TREE_RAIL_WIDTH,
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
clampTreeRailWidth,
|
||||
selectIsAgentListOpen,
|
||||
selectIsFileExplorerOpen,
|
||||
selectPanelVisibility,
|
||||
selectIsCompactFileExplorerOpen,
|
||||
};
|
||||
|
||||
export type ExpandedPathsUpdate = string[] | ((currentPaths: string[]) => string[]);
|
||||
@@ -86,7 +73,6 @@ export interface PanelState {
|
||||
diffCollapsedFoldersByWorkspace: Record<string, string[]>;
|
||||
collapsedFilePathsByWorkspace: Record<string, string[]>;
|
||||
sidebarWidth: number;
|
||||
explorerWidth: number;
|
||||
explorerSortOption: SortOption;
|
||||
explorerShowHiddenFiles: boolean;
|
||||
treeRailWidth: number;
|
||||
@@ -100,12 +86,11 @@ export interface PanelState {
|
||||
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;
|
||||
openCompactFileExplorer: (checkout: ExplorerCheckoutContext) => void;
|
||||
toggleCompactFileExplorer: (checkout: ExplorerCheckoutContext) => void;
|
||||
|
||||
// File explorer settings actions
|
||||
setExplorerTab: (tab: ExplorerTab) => void;
|
||||
@@ -115,7 +100,6 @@ export interface PanelState {
|
||||
setCollapsedFilePathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
|
||||
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
|
||||
setSidebarWidth: (width: number) => void;
|
||||
setExplorerWidth: (width: number) => void;
|
||||
setExplorerSortOption: (option: SortOption) => void;
|
||||
toggleExplorerShowHiddenFiles: () => void;
|
||||
setTreeRailWidth: (width: number) => void;
|
||||
@@ -140,7 +124,6 @@ export const usePanelStore = create<PanelState>()(
|
||||
// Desktop defaults based on platform
|
||||
desktop: {
|
||||
agentListOpen: DEFAULT_DESKTOP_OPEN,
|
||||
fileExplorerOpen: false,
|
||||
focusModeEnabled: false,
|
||||
},
|
||||
|
||||
@@ -151,7 +134,6 @@ export const usePanelStore = create<PanelState>()(
|
||||
diffCollapsedFoldersByWorkspace: {},
|
||||
collapsedFilePathsByWorkspace: {},
|
||||
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
|
||||
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||
explorerSortOption: "name",
|
||||
explorerShowHiddenFiles: true,
|
||||
treeRailWidth: DEFAULT_TREE_RAIL_WIDTH,
|
||||
@@ -201,14 +183,6 @@ export const usePanelStore = create<PanelState>()(
|
||||
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) {
|
||||
@@ -242,11 +216,11 @@ export const usePanelStore = create<PanelState>()(
|
||||
};
|
||||
}),
|
||||
|
||||
openFileExplorerForCheckout: (input) =>
|
||||
set((state) => buildOpenFileExplorerPatch(state, input)),
|
||||
openCompactFileExplorer: (checkout) =>
|
||||
set((state) => buildOpenFileExplorerPatch(state, checkout)),
|
||||
|
||||
toggleFileExplorerForCheckout: (input) =>
|
||||
set((state) => buildToggleFileExplorerPatch(state, input)),
|
||||
toggleCompactFileExplorer: (checkout) =>
|
||||
set((state) => buildToggleFileExplorerPatch(state, checkout)),
|
||||
|
||||
setExplorerTab: (tab) => set({ explorerTab: tab }),
|
||||
setExplorerTabForCheckout: ({ serverId, cwd, isGit, tab }) =>
|
||||
@@ -300,7 +274,6 @@ export const usePanelStore = create<PanelState>()(
|
||||
}),
|
||||
})),
|
||||
setSidebarWidth: (width) => set({ sidebarWidth: clampSidebarWidth(width) }),
|
||||
setExplorerWidth: (width) => set({ explorerWidth: clampExplorerWidth(width) }),
|
||||
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
|
||||
toggleExplorerShowHiddenFiles: () =>
|
||||
set((state) => ({ explorerShowHiddenFiles: !state.explorerShowHiddenFiles })),
|
||||
@@ -308,9 +281,9 @@ export const usePanelStore = create<PanelState>()(
|
||||
}),
|
||||
{
|
||||
name: "panel-state",
|
||||
version: 14,
|
||||
version: 15,
|
||||
storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema),
|
||||
migrate: (persistedState, version) => migratePanelState(persistedState, version, { isWeb }),
|
||||
migrate: (persistedState, version) => migratePanelState(persistedState, version),
|
||||
partialize: (state) => ({
|
||||
desktop: state.desktop,
|
||||
explorerTab: state.explorerTab,
|
||||
@@ -319,7 +292,6 @@ export const usePanelStore = create<PanelState>()(
|
||||
diffCollapsedFoldersByWorkspace: state.diffCollapsedFoldersByWorkspace,
|
||||
collapsedFilePathsByWorkspace: state.collapsedFilePathsByWorkspace,
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
explorerWidth: state.explorerWidth,
|
||||
explorerSortOption: state.explorerSortOption,
|
||||
explorerShowHiddenFiles: state.explorerShowHiddenFiles,
|
||||
treeRailWidth: state.treeRailWidth,
|
||||
@@ -327,58 +299,3 @@ export const usePanelStore = create<PanelState>()(
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 treeRailWidth = usePanelStore((state) => state.treeRailWidth);
|
||||
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 setTreeRailWidth = usePanelStore((state) => state.setTreeRailWidth);
|
||||
|
||||
return {
|
||||
isAgentListOpen,
|
||||
isFileExplorerOpen,
|
||||
openAgentList: () => openAgentListForLayout({ isCompact: isMobile }),
|
||||
closeAgentList: () => closeAgentListForLayout({ isCompact: isMobile }),
|
||||
closeFileExplorer: isMobile ? showMobileAgent : closeDesktopFileExplorer,
|
||||
toggleAgentList: () => toggleAgentListForLayout({ isCompact: isMobile }),
|
||||
explorerTab,
|
||||
explorerTabByCheckout,
|
||||
explorerWidth,
|
||||
explorerSortOption,
|
||||
treeRailWidth,
|
||||
setExplorerTab,
|
||||
setExplorerTabForCheckout,
|
||||
activateExplorerTabForCheckout,
|
||||
setExplorerWidth,
|
||||
setExplorerSortOption,
|
||||
setTreeRailWidth,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,9 +10,8 @@ import {
|
||||
DEFAULT_TREE_RAIL_WIDTH,
|
||||
migratePanelState,
|
||||
selectIsAgentListOpen,
|
||||
selectIsFileExplorerOpen,
|
||||
selectIsCompactFileExplorerOpen,
|
||||
setMobilePanelTarget,
|
||||
selectPanelVisibility,
|
||||
type PanelCoreState,
|
||||
} from "./state";
|
||||
|
||||
@@ -21,7 +20,6 @@ function makePanelState(overrides: Partial<PanelCoreState> = {}): PanelCoreState
|
||||
mobilePanel: { target: "agent", revision: 0 },
|
||||
desktop: {
|
||||
agentListOpen: false,
|
||||
fileExplorerOpen: false,
|
||||
focusModeEnabled: false,
|
||||
},
|
||||
explorerTab: "changes",
|
||||
@@ -101,44 +99,39 @@ describe("panel-store explorer tab resolution", () => {
|
||||
|
||||
describe("panel-store migration", () => {
|
||||
it("defaults hidden-file visibility to showing hidden files", () => {
|
||||
const state = migratePanelState({}, 10, { isWeb: false });
|
||||
const state = migratePanelState({}, 10);
|
||||
|
||||
expect(state.explorerShowHiddenFiles).toBe(true);
|
||||
});
|
||||
|
||||
it("initializes diffCollapsedFoldersByWorkspace for pre-v12 state", () => {
|
||||
const state = migratePanelState({}, 11, { isWeb: false });
|
||||
const state = migratePanelState({}, 11);
|
||||
|
||||
expect(state.diffCollapsedFoldersByWorkspace).toEqual({});
|
||||
});
|
||||
|
||||
it("preserves an existing diffCollapsedFoldersByWorkspace map", () => {
|
||||
const state = migratePanelState({ diffCollapsedFoldersByWorkspace: { ws: ["src/app"] } }, 12, {
|
||||
isWeb: false,
|
||||
});
|
||||
const state = migratePanelState({ diffCollapsedFoldersByWorkspace: { ws: ["src/app"] } }, 12);
|
||||
|
||||
expect(state.diffCollapsedFoldersByWorkspace).toEqual({ ws: ["src/app"] });
|
||||
});
|
||||
|
||||
it("initializes and preserves collapsed diff file paths by workspace", () => {
|
||||
expect(migratePanelState({}, 14, { isWeb: false }).collapsedFilePathsByWorkspace).toEqual({});
|
||||
expect(migratePanelState({}, 14).collapsedFilePathsByWorkspace).toEqual({});
|
||||
expect(
|
||||
migratePanelState({ collapsedFilePathsByWorkspace: { ws: ["src/app.ts"] } }, 14, {
|
||||
isWeb: false,
|
||||
}).collapsedFilePathsByWorkspace,
|
||||
migratePanelState({ collapsedFilePathsByWorkspace: { ws: ["src/app.ts"] } }, 14)
|
||||
.collapsedFilePathsByWorkspace,
|
||||
).toEqual({ ws: ["src/app.ts"] });
|
||||
});
|
||||
|
||||
it("drops the retired per-file diff expansion state", () => {
|
||||
const state = migratePanelState({ diffExpandedPathsByWorkspace: { ws: ["src/app.ts"] } }, 13, {
|
||||
isWeb: false,
|
||||
});
|
||||
const state = migratePanelState({ diffExpandedPathsByWorkspace: { ws: ["src/app.ts"] } }, 13);
|
||||
|
||||
expect(state.diffExpandedPathsByWorkspace).toBeUndefined();
|
||||
});
|
||||
|
||||
it("replaces the dead explorer split ratio with the shared tree rail width", () => {
|
||||
const state = migratePanelState({ explorerFilesSplitRatio: 0.5 }, 12, { isWeb: false });
|
||||
const state = migratePanelState({ explorerFilesSplitRatio: 0.5 }, 12);
|
||||
|
||||
expect(state.explorerFilesSplitRatio).toBeUndefined();
|
||||
expect(state.treeRailWidth).toBe(DEFAULT_TREE_RAIL_WIDTH);
|
||||
@@ -148,7 +141,6 @@ describe("panel-store migration", () => {
|
||||
const state = migratePanelState(
|
||||
{ mobileView: "agent-list", mobilePanel: { target: "file-explorer", revision: 42 } },
|
||||
11,
|
||||
{ isWeb: false },
|
||||
);
|
||||
|
||||
expect(state.mobileView).toBeUndefined();
|
||||
@@ -170,33 +162,24 @@ describe("panel-store visibility selectors", () => {
|
||||
it("uses the mobile panel target for compact layout visibility", () => {
|
||||
const state = makePanelState({
|
||||
mobilePanel: { target: "file-explorer", revision: 1 },
|
||||
desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false },
|
||||
desktop: { agentListOpen: true, focusModeEnabled: false },
|
||||
});
|
||||
|
||||
expect(selectPanelVisibility(state, { isCompact: true })).toEqual({
|
||||
isAgentListOpen: false,
|
||||
isFileExplorerOpen: true,
|
||||
});
|
||||
expect(selectIsAgentListOpen(state, { isCompact: true })).toBe(false);
|
||||
expect(selectIsFileExplorerOpen(state, { isCompact: true })).toBe(true);
|
||||
expect(selectIsCompactFileExplorerOpen(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses desktop flags for expanded layout visibility", () => {
|
||||
it("uses the desktop flag for the expanded agent list", () => {
|
||||
const state = makePanelState({
|
||||
mobilePanel: { target: "file-explorer", revision: 1 },
|
||||
desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false },
|
||||
desktop: { agentListOpen: true, focusModeEnabled: false },
|
||||
});
|
||||
|
||||
expect(selectPanelVisibility(state, { isCompact: false })).toEqual({
|
||||
isAgentListOpen: true,
|
||||
isFileExplorerOpen: false,
|
||||
});
|
||||
expect(selectIsAgentListOpen(state, { isCompact: false })).toBe(true);
|
||||
expect(selectIsFileExplorerOpen(state, { isCompact: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("panel-store checkout-intent file explorer actions", () => {
|
||||
describe("panel-store compact file explorer actions", () => {
|
||||
it("opens the compact explorer and resolves the tab from the explicit checkout", () => {
|
||||
const checkout = { serverId: "server-1", cwd: "/tmp/repo", isGit: true };
|
||||
const key = buildExplorerCheckoutKey(checkout.serverId, checkout.cwd)!;
|
||||
@@ -205,42 +188,25 @@ describe("panel-store checkout-intent file explorer actions", () => {
|
||||
explorerTabByCheckout: { [key]: "files" },
|
||||
});
|
||||
|
||||
const patch = buildOpenFileExplorerPatch(state, { isCompact: true, checkout });
|
||||
const patch = buildOpenFileExplorerPatch(state, checkout);
|
||||
|
||||
expect(patch.mobilePanel).toEqual({ target: "file-explorer", revision: 1 });
|
||||
expect(patch.desktop).toBeUndefined();
|
||||
expect(patch.explorerTab).toBe("files");
|
||||
});
|
||||
|
||||
it("opens the expanded explorer and resolves the tab from the explicit checkout", () => {
|
||||
const checkout = { serverId: "server-1", cwd: "/tmp/repo", isGit: true };
|
||||
const key = buildExplorerCheckoutKey(checkout.serverId, checkout.cwd)!;
|
||||
const state = makePanelState({
|
||||
explorerTab: "changes",
|
||||
explorerTabByCheckout: { [key]: "files" },
|
||||
});
|
||||
|
||||
const patch = buildOpenFileExplorerPatch(state, { isCompact: false, checkout });
|
||||
|
||||
expect(patch.mobilePanel).toBeUndefined();
|
||||
expect(patch.desktop?.fileExplorerOpen).toBe(true);
|
||||
expect(patch.explorerTab).toBe("files");
|
||||
});
|
||||
|
||||
it("toggles the explorer closed without changing the active tab", () => {
|
||||
const state = makePanelState({
|
||||
desktop: { agentListOpen: false, fileExplorerOpen: true, focusModeEnabled: false },
|
||||
mobilePanel: { target: "file-explorer", revision: 1 },
|
||||
explorerTab: "files",
|
||||
});
|
||||
|
||||
const patch = buildToggleFileExplorerPatch(state, {
|
||||
isCompact: false,
|
||||
checkout: { serverId: "server-1", cwd: "/tmp/repo", isGit: true },
|
||||
serverId: "server-1",
|
||||
cwd: "/tmp/repo",
|
||||
isGit: true,
|
||||
});
|
||||
|
||||
expect(patch).toEqual({
|
||||
desktop: { agentListOpen: false, fileExplorerOpen: false, focusModeEnabled: false },
|
||||
});
|
||||
expect(patch).toEqual({ mobilePanel: { target: "agent", revision: 2 } });
|
||||
});
|
||||
|
||||
it("coerces changes to files for a non-git checkout", () => {
|
||||
@@ -251,20 +217,19 @@ describe("panel-store checkout-intent file explorer actions", () => {
|
||||
explorerTabByCheckout: { [key]: "changes" },
|
||||
});
|
||||
|
||||
const patch = buildOpenFileExplorerPatch(state, { isCompact: false, checkout });
|
||||
|
||||
expect(patch.explorerTab).toBe("files");
|
||||
expect(buildOpenFileExplorerPatch(state, checkout).explorerTab).toBe("files");
|
||||
});
|
||||
|
||||
it("opens with the default files tab for an explicit non-git checkout with no stored tab", () => {
|
||||
const state = makePanelState({ explorerTab: "changes", explorerTabByCheckout: {} });
|
||||
|
||||
const patch = buildOpenFileExplorerPatch(state, {
|
||||
isCompact: false,
|
||||
checkout: { serverId: "server-1", cwd: "/tmp/non-git", isGit: false },
|
||||
serverId: "server-1",
|
||||
cwd: "/tmp/non-git",
|
||||
isGit: false,
|
||||
});
|
||||
|
||||
expect(patch.desktop?.fileExplorerOpen).toBe(true);
|
||||
expect(patch.mobilePanel).toEqual({ target: "file-explorer", revision: 1 });
|
||||
expect(patch.explorerTab).toBe("files");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ export interface MobilePanelSelection {
|
||||
|
||||
export interface DesktopSidebarState {
|
||||
agentListOpen: boolean;
|
||||
fileExplorerOpen: boolean;
|
||||
focusModeEnabled: boolean;
|
||||
}
|
||||
|
||||
@@ -26,28 +25,14 @@ 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;
|
||||
@@ -66,36 +51,20 @@ 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;
|
||||
return input.isCompact ? state.mobilePanel.target === "agent-list" : state.desktop.agentListOpen;
|
||||
}
|
||||
|
||||
export function selectIsFileExplorerOpen(state: PanelCoreState, input: PanelLayoutInput): boolean {
|
||||
return selectPanelVisibility(state, input).isFileExplorerOpen;
|
||||
/**
|
||||
* The overlay explorer exists only on compact layouts. Wider layouts render the
|
||||
* explorer as workspace tabs — see `@/workspace-tabs/explorer-surface`.
|
||||
*/
|
||||
export function selectIsCompactFileExplorerOpen(state: PanelCoreState): boolean {
|
||||
return state.mobilePanel.target === "file-explorer";
|
||||
}
|
||||
|
||||
export function setMobilePanelTarget(
|
||||
@@ -121,54 +90,42 @@ function resolveExplorerTabFromCheckout(
|
||||
}
|
||||
|
||||
export interface OpenFileExplorerPatch {
|
||||
mobilePanel?: MobilePanelSelection;
|
||||
desktop?: DesktopSidebarState;
|
||||
mobilePanel: MobilePanelSelection;
|
||||
explorerTab: ExplorerTab;
|
||||
}
|
||||
|
||||
export function buildOpenFileExplorerPatch(
|
||||
state: PanelCoreState,
|
||||
input: ExplorerPanelIntent,
|
||||
checkout: ExplorerCheckoutContext,
|
||||
): 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,
|
||||
mobilePanel: setMobilePanelTarget(state.mobilePanel, "file-explorer"),
|
||||
explorerTab: resolveExplorerTabFromCheckout(state, checkout),
|
||||
};
|
||||
}
|
||||
|
||||
export type ToggleFileExplorerPatch =
|
||||
| OpenFileExplorerPatch
|
||||
| { mobilePanel: MobilePanelSelection }
|
||||
| { desktop: DesktopSidebarState };
|
||||
export type ToggleFileExplorerPatch = OpenFileExplorerPatch | { mobilePanel: MobilePanelSelection };
|
||||
|
||||
export function buildToggleFileExplorerPatch(
|
||||
state: PanelCoreState,
|
||||
input: ExplorerPanelIntent,
|
||||
checkout: ExplorerCheckoutContext,
|
||||
): ToggleFileExplorerPatch {
|
||||
const isOpen = selectIsFileExplorerOpen(state, input);
|
||||
if (!isOpen) {
|
||||
return buildOpenFileExplorerPatch(state, input);
|
||||
if (!selectIsCompactFileExplorerOpen(state)) {
|
||||
return buildOpenFileExplorerPatch(state, checkout);
|
||||
}
|
||||
if (input.isCompact) {
|
||||
return { mobilePanel: setMobilePanelTarget(state.mobilePanel, "agent") };
|
||||
}
|
||||
return { desktop: { ...state.desktop, fileExplorerOpen: false } };
|
||||
return { mobilePanel: setMobilePanelTarget(state.mobilePanel, "agent") };
|
||||
}
|
||||
|
||||
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(),
|
||||
// Accepted only so migration can discard the former docked explorer sidebar.
|
||||
// The schema is strict and a parse failure drops the whole entry, so removing
|
||||
// this key outright would wipe every upgrading user's panel state.
|
||||
fileExplorerOpen: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const PanelPersistedStateSchema = z.strictObject({
|
||||
@@ -188,6 +145,7 @@ export const PanelPersistedStateSchema = z.strictObject({
|
||||
diffCollapsedFoldersByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
|
||||
collapsedFilePathsByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
|
||||
sidebarWidth: z.number().optional(),
|
||||
// Accepted only so migration can discard the former docked explorer sidebar width.
|
||||
explorerWidth: z.number().optional(),
|
||||
explorerSortOption: z.enum(["name", "modified", "size"]).optional(),
|
||||
explorerShowHiddenFiles: z.boolean().optional(),
|
||||
@@ -197,22 +155,6 @@ export const PanelPersistedStateSchema = z.strictObject({
|
||||
|
||||
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 ||
|
||||
@@ -260,20 +202,16 @@ function migrateTreeRailWidth(state: MigratablePanelState, version: number): voi
|
||||
state.treeRailWidth = clampTreeRailWidth(state.treeRailWidth);
|
||||
}
|
||||
|
||||
export function migratePanelState(
|
||||
persistedState: unknown,
|
||||
version: number,
|
||||
options: { isWeb: boolean },
|
||||
): MigratablePanelState {
|
||||
export function migratePanelState(persistedState: unknown, version: number): 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);
|
||||
// The docked explorer sidebar is gone; wider layouts render the explorer as
|
||||
// workspace tabs. Left behind, `fileExplorerOpen` kept drawing a sidebar that
|
||||
// nothing could close.
|
||||
delete state.explorerWidth;
|
||||
if (state.desktop) {
|
||||
delete state.desktop.fileExplorerOpen;
|
||||
}
|
||||
if (!isExplorerTab(state.explorerTab)) {
|
||||
state.explorerTab = "changes";
|
||||
|
||||
Reference in New Issue
Block a user