Improve workspace navigation and render isolation
This commit is contained in:
@@ -3,21 +3,3 @@ export interface ExplorerCheckoutContext {
|
||||
cwd: string;
|
||||
isGit: boolean;
|
||||
}
|
||||
|
||||
let activeExplorerCheckout: ExplorerCheckoutContext | null = null;
|
||||
|
||||
export function getActiveExplorerCheckout(): ExplorerCheckoutContext | null {
|
||||
return activeExplorerCheckout;
|
||||
}
|
||||
|
||||
export function setActiveExplorerCheckout(checkout: ExplorerCheckoutContext | null) {
|
||||
if (
|
||||
activeExplorerCheckout?.serverId === checkout?.serverId &&
|
||||
activeExplorerCheckout?.cwd === checkout?.cwd &&
|
||||
activeExplorerCheckout?.isGit === checkout?.isGit
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeExplorerCheckout = checkout;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
isWeb: true,
|
||||
}));
|
||||
|
||||
function installWindowStub(pathname: string) {
|
||||
const windowStub = {
|
||||
location: {
|
||||
href: "",
|
||||
origin: "http://localhost",
|
||||
pathname: "",
|
||||
search: "",
|
||||
hash: "",
|
||||
},
|
||||
history: {
|
||||
pushState: vi.fn((_state: unknown, _title: string, url: string) => {
|
||||
updateLocation(url);
|
||||
}),
|
||||
replaceState: vi.fn((_state: unknown, _title: string, url: string) => {
|
||||
updateLocation(url);
|
||||
}),
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
|
||||
function updateLocation(url: string) {
|
||||
const next = new URL(url, windowStub.location.origin);
|
||||
windowStub.location = {
|
||||
href: next.href,
|
||||
origin: next.origin,
|
||||
pathname: next.pathname,
|
||||
search: next.search,
|
||||
hash: next.hash,
|
||||
};
|
||||
}
|
||||
|
||||
updateLocation(pathname);
|
||||
vi.stubGlobal("window", windowStub);
|
||||
return windowStub;
|
||||
}
|
||||
|
||||
function createNavigationRef(serverId: string, workspaceId: string) {
|
||||
return {
|
||||
current: {
|
||||
getCurrentRoute: () => ({
|
||||
params: { serverId, workspaceId },
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createNavigationPathRef(path: string) {
|
||||
return {
|
||||
current: {
|
||||
getCurrentRoute: () => ({
|
||||
path,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createNavigationPathWithParamsRef(path: string, serverId: string, workspaceId: string) {
|
||||
return {
|
||||
current: {
|
||||
getCurrentRoute: () => ({
|
||||
path,
|
||||
params: { serverId, workspaceId },
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("navigation active workspace store", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps explicit web workspace activation ahead of a stale navigation route sync", async () => {
|
||||
installWindowStub("/h/server-1/workspace/workspace-a");
|
||||
const store = await import("@/stores/navigation-active-workspace-store");
|
||||
|
||||
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
|
||||
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
|
||||
store.activateNavigationWorkspaceSelection(
|
||||
{ serverId: "server-1", workspaceId: "workspace-b" },
|
||||
{ updateBrowserHistory: true, historyMode: "push" },
|
||||
);
|
||||
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
|
||||
|
||||
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-b",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears a stale browser workspace when navigation sync reports a non-workspace route", async () => {
|
||||
installWindowStub("/h/server-1/workspace/workspace-a");
|
||||
const store = await import("@/stores/navigation-active-workspace-store");
|
||||
|
||||
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
|
||||
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
|
||||
store.syncNavigationActiveWorkspace(createNavigationPathRef("/h/server-1/sessions"));
|
||||
|
||||
expect(store.getNavigationActiveWorkspaceSelection()).toBeNull();
|
||||
});
|
||||
|
||||
it("clears stale workspace params when navigation sync reports a non-workspace path", async () => {
|
||||
installWindowStub("/h/server-1/workspace/workspace-a");
|
||||
const store = await import("@/stores/navigation-active-workspace-store");
|
||||
|
||||
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
|
||||
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
|
||||
store.syncNavigationActiveWorkspace(
|
||||
createNavigationPathWithParamsRef("/h/server-1/sessions", "server-1", "workspace-a"),
|
||||
);
|
||||
|
||||
expect(store.getNavigationActiveWorkspaceSelection()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
@@ -16,15 +17,24 @@ interface ActivateWorkspaceSelectionOptions {
|
||||
historyMode?: "push" | "replace";
|
||||
}
|
||||
|
||||
type NavigationRouteLike = {
|
||||
name?: unknown;
|
||||
params?: unknown;
|
||||
path?: unknown;
|
||||
type NavigationRouteParams = {
|
||||
serverId?: string | string[];
|
||||
workspaceId?: string | string[];
|
||||
};
|
||||
|
||||
type NavigationRouteLike = {
|
||||
params?: NavigationRouteParams | null;
|
||||
path?: string | null;
|
||||
};
|
||||
|
||||
type NavigationWorkspaceRouteState =
|
||||
| { kind: "workspace"; selection: ActiveWorkspaceSelection }
|
||||
| { kind: "nonWorkspace" }
|
||||
| { kind: "unknown" };
|
||||
|
||||
interface NavigationObserverRef {
|
||||
current: {
|
||||
getCurrentRoute(): unknown;
|
||||
getCurrentRoute(): NavigationRouteLike | null | undefined;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -83,7 +93,7 @@ function writeBrowserWorkspaceUrl(
|
||||
}
|
||||
|
||||
function extractActiveWorkspaceFromRoute(
|
||||
route: NavigationRouteLike | undefined,
|
||||
route: NavigationRouteLike | null | undefined,
|
||||
): ActiveWorkspaceSelection | null {
|
||||
if (!route) {
|
||||
return null;
|
||||
@@ -96,17 +106,12 @@ function extractActiveWorkspaceFromRoute(
|
||||
}
|
||||
}
|
||||
|
||||
const params =
|
||||
route.params && typeof route.params === "object"
|
||||
? (route.params as {
|
||||
serverId?: string | string[];
|
||||
workspaceId?: string | string[];
|
||||
})
|
||||
: null;
|
||||
const serverValue = Array.isArray(params?.serverId) ? params?.serverId[0] : params?.serverId;
|
||||
const workspaceValue = Array.isArray(params?.workspaceId)
|
||||
? params?.workspaceId[0]
|
||||
: params?.workspaceId;
|
||||
const serverValue = Array.isArray(route.params?.serverId)
|
||||
? route.params.serverId[0]
|
||||
: route.params?.serverId;
|
||||
const workspaceValue = Array.isArray(route.params?.workspaceId)
|
||||
? route.params.workspaceId[0]
|
||||
: route.params?.workspaceId;
|
||||
const serverId = typeof serverValue === "string" ? serverValue.trim() : "";
|
||||
const workspaceId =
|
||||
typeof workspaceValue === "string"
|
||||
@@ -120,20 +125,55 @@ function extractActiveWorkspaceFromRoute(
|
||||
return { serverId, workspaceId };
|
||||
}
|
||||
|
||||
function classifyNavigationWorkspaceRoute(
|
||||
route: NavigationRouteLike | null | undefined,
|
||||
): NavigationWorkspaceRouteState {
|
||||
if (!route) {
|
||||
return { kind: "unknown" };
|
||||
}
|
||||
|
||||
if (typeof route.path === "string") {
|
||||
const selection = parseHostWorkspaceRouteFromPathname(route.path);
|
||||
if (selection) {
|
||||
return { kind: "workspace", selection };
|
||||
}
|
||||
return { kind: "nonWorkspace" };
|
||||
}
|
||||
|
||||
const selection = extractActiveWorkspaceFromRoute(route);
|
||||
if (selection) {
|
||||
return { kind: "workspace", selection };
|
||||
}
|
||||
|
||||
return { kind: "unknown" };
|
||||
}
|
||||
|
||||
function getActiveWorkspaceForNavigationSync(
|
||||
route: NavigationRouteLike | null | undefined,
|
||||
): ActiveWorkspaceSelection | null {
|
||||
const routeState = classifyNavigationWorkspaceRoute(route);
|
||||
if (routeState.kind === "workspace") {
|
||||
const browserWorkspace = getBrowserLocationWorkspace();
|
||||
return browserWorkspace ?? routeState.selection;
|
||||
}
|
||||
|
||||
if (routeState.kind === "nonWorkspace") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getBrowserLocationWorkspace();
|
||||
}
|
||||
|
||||
export function syncNavigationActiveWorkspace(navigationRef: NavigationObserverRef) {
|
||||
emitIfChanged(
|
||||
extractActiveWorkspaceFromRoute(
|
||||
navigationRef.current?.getCurrentRoute() as NavigationRouteLike | undefined,
|
||||
),
|
||||
);
|
||||
emitIfChanged(getActiveWorkspaceForNavigationSync(navigationRef.current?.getCurrentRoute()));
|
||||
}
|
||||
|
||||
export function activateNavigationWorkspaceSelection(
|
||||
next: ActiveWorkspaceSelection,
|
||||
options: ActivateWorkspaceSelectionOptions = {},
|
||||
) {
|
||||
emitIfChanged(next);
|
||||
writeBrowserWorkspaceUrl(next, options);
|
||||
emitIfChanged(next);
|
||||
}
|
||||
|
||||
export function getNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||
@@ -161,3 +201,45 @@ export function addBrowserActiveWorkspaceLocationListener(): () => void {
|
||||
export function useNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
export function useIsNavigationWorkspaceSelected(input: {
|
||||
serverId: string | null;
|
||||
workspaceId: string | null;
|
||||
enabled?: boolean;
|
||||
}): boolean {
|
||||
const enabled = input.enabled !== false;
|
||||
return useSyncExternalStoreWithSelector(
|
||||
subscribe,
|
||||
getSnapshot,
|
||||
getSnapshot,
|
||||
(selection) =>
|
||||
enabled &&
|
||||
Boolean(input.serverId) &&
|
||||
Boolean(input.workspaceId) &&
|
||||
selection?.serverId === input.serverId &&
|
||||
selection.workspaceId === input.workspaceId,
|
||||
Object.is,
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsNavigationProjectActive(input: {
|
||||
serverId: string | null;
|
||||
workspaceIds: readonly string[];
|
||||
enabled?: boolean;
|
||||
}): boolean {
|
||||
const enabled = input.enabled !== false;
|
||||
const workspaceIdsKey = input.workspaceIds.join("\0");
|
||||
const workspaceIdSet = useMemo(() => new Set(input.workspaceIds), [workspaceIdsKey]);
|
||||
|
||||
return useSyncExternalStoreWithSelector(
|
||||
subscribe,
|
||||
getSnapshot,
|
||||
getSnapshot,
|
||||
(selection) =>
|
||||
enabled &&
|
||||
Boolean(input.serverId) &&
|
||||
selection?.serverId === input.serverId &&
|
||||
workspaceIdSet.has(selection.workspaceId),
|
||||
Object.is,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
return {
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn(async (key: string) => {
|
||||
storage.delete(key);
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
buildExplorerCheckoutKey,
|
||||
resolveExplorerTabForCheckout,
|
||||
} from "@/stores/explorer-tab-memory";
|
||||
import {
|
||||
selectIsAgentListOpen,
|
||||
selectIsFileExplorerOpen,
|
||||
selectPanelVisibility,
|
||||
usePanelStore,
|
||||
type PanelState,
|
||||
} from "@/stores/panel-store";
|
||||
|
||||
function resetPanelStore() {
|
||||
usePanelStore.setState({
|
||||
mobileView: "agent",
|
||||
desktop: {
|
||||
agentListOpen: false,
|
||||
fileExplorerOpen: false,
|
||||
focusModeEnabled: false,
|
||||
},
|
||||
explorerTab: "changes",
|
||||
explorerTabByCheckout: {},
|
||||
});
|
||||
}
|
||||
|
||||
function createPanelState(input: {
|
||||
mobileView: PanelState["mobileView"];
|
||||
agentListOpen: boolean;
|
||||
fileExplorerOpen: boolean;
|
||||
}): PanelState {
|
||||
return {
|
||||
...usePanelStore.getState(),
|
||||
mobileView: input.mobileView,
|
||||
desktop: {
|
||||
...usePanelStore.getState().desktop,
|
||||
agentListOpen: input.agentListOpen,
|
||||
fileExplorerOpen: input.fileExplorerOpen,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetPanelStore();
|
||||
});
|
||||
|
||||
describe("panel-store explorer tab resolution", () => {
|
||||
const serverId = "server-1";
|
||||
@@ -72,3 +128,120 @@ describe("panel-store explorer tab resolution", () => {
|
||||
).toBe("files");
|
||||
});
|
||||
});
|
||||
|
||||
describe("panel-store visibility selectors", () => {
|
||||
it("uses mobileView for compact layout visibility", () => {
|
||||
const state = createPanelState({
|
||||
mobileView: "file-explorer",
|
||||
agentListOpen: true,
|
||||
fileExplorerOpen: 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);
|
||||
});
|
||||
|
||||
it("uses desktop flags for expanded layout visibility", () => {
|
||||
const state = createPanelState({
|
||||
mobileView: "file-explorer",
|
||||
agentListOpen: true,
|
||||
fileExplorerOpen: 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", () => {
|
||||
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)!;
|
||||
usePanelStore.setState({
|
||||
explorerTab: "changes",
|
||||
explorerTabByCheckout: { [key]: "files" },
|
||||
});
|
||||
|
||||
usePanelStore.getState().openFileExplorerForCheckout({
|
||||
isCompact: true,
|
||||
checkout,
|
||||
});
|
||||
|
||||
expect(usePanelStore.getState().mobileView).toBe("file-explorer");
|
||||
expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(false);
|
||||
expect(usePanelStore.getState().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)!;
|
||||
usePanelStore.setState({
|
||||
explorerTab: "changes",
|
||||
explorerTabByCheckout: { [key]: "files" },
|
||||
});
|
||||
|
||||
usePanelStore.getState().openFileExplorerForCheckout({
|
||||
isCompact: false,
|
||||
checkout,
|
||||
});
|
||||
|
||||
expect(usePanelStore.getState().mobileView).toBe("agent");
|
||||
expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(true);
|
||||
expect(usePanelStore.getState().explorerTab).toBe("files");
|
||||
});
|
||||
|
||||
it("toggles the explorer closed without changing the active tab", () => {
|
||||
usePanelStore.setState({
|
||||
desktop: {
|
||||
agentListOpen: false,
|
||||
fileExplorerOpen: true,
|
||||
focusModeEnabled: false,
|
||||
},
|
||||
explorerTab: "files",
|
||||
});
|
||||
|
||||
usePanelStore.getState().toggleFileExplorerForCheckout({
|
||||
isCompact: false,
|
||||
checkout: { serverId: "server-1", cwd: "/tmp/repo", isGit: true },
|
||||
});
|
||||
|
||||
expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(false);
|
||||
expect(usePanelStore.getState().explorerTab).toBe("files");
|
||||
});
|
||||
|
||||
it("coerces changes to files for a non-git checkout", () => {
|
||||
const checkout = { serverId: "server-1", cwd: "/tmp/repo", isGit: false };
|
||||
const key = buildExplorerCheckoutKey(checkout.serverId, checkout.cwd)!;
|
||||
usePanelStore.setState({
|
||||
explorerTab: "changes",
|
||||
explorerTabByCheckout: { [key]: "changes" },
|
||||
});
|
||||
|
||||
usePanelStore.getState().openFileExplorerForCheckout({
|
||||
isCompact: false,
|
||||
checkout,
|
||||
});
|
||||
|
||||
expect(usePanelStore.getState().explorerTab).toBe("files");
|
||||
});
|
||||
|
||||
it("opens with the default files tab for an explicit non-git checkout with no stored tab", () => {
|
||||
usePanelStore.setState({ explorerTab: "changes", explorerTabByCheckout: {} });
|
||||
|
||||
usePanelStore.getState().openFileExplorerForCheckout({
|
||||
isCompact: false,
|
||||
checkout: { serverId: "server-1", cwd: "/tmp/non-git", isGit: false },
|
||||
});
|
||||
|
||||
expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(true);
|
||||
expect(usePanelStore.getState().explorerTab).toBe("files");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,7 @@ import {
|
||||
resolveExplorerTabForCheckout,
|
||||
type ExplorerTab,
|
||||
} from "./explorer-tab-memory";
|
||||
import {
|
||||
getActiveExplorerCheckout,
|
||||
type ExplorerCheckoutContext,
|
||||
} from "./explorer-checkout-context";
|
||||
import { type ExplorerCheckoutContext } from "./explorer-checkout-context";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
export type { ExplorerTab } from "./explorer-tab-memory";
|
||||
export type { ExplorerCheckoutContext } from "./explorer-checkout-context";
|
||||
@@ -27,7 +24,7 @@ export type { ExplorerCheckoutContext } from "./explorer-checkout-context";
|
||||
* This makes impossible states unrepresentable - you cannot have both
|
||||
* sidebars open at the same time on mobile.
|
||||
*/
|
||||
type MobilePanelView = "agent" | "agent-list" | "file-explorer";
|
||||
export type MobilePanelView = "agent" | "agent-list" | "file-explorer";
|
||||
|
||||
/**
|
||||
* Desktop sidebar state.
|
||||
@@ -55,7 +52,20 @@ 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;
|
||||
|
||||
interface PanelState {
|
||||
export interface PanelVisibilityState {
|
||||
isAgentListOpen: boolean;
|
||||
isFileExplorerOpen: boolean;
|
||||
}
|
||||
|
||||
export interface PanelLayoutInput {
|
||||
isCompact: boolean;
|
||||
}
|
||||
|
||||
export interface ExplorerPanelIntent extends PanelLayoutInput {
|
||||
checkout: ExplorerCheckoutContext;
|
||||
}
|
||||
|
||||
export interface PanelState {
|
||||
// Mobile: which panel is currently shown
|
||||
mobileView: MobilePanelView;
|
||||
|
||||
@@ -76,16 +86,16 @@ interface PanelState {
|
||||
toggleFocusMode: () => void;
|
||||
showMobileAgent: () => void;
|
||||
showMobileAgentList: () => void;
|
||||
showMobileFileExplorer: () => void;
|
||||
toggleMobileAgentList: () => void;
|
||||
toggleMobileFileExplorer: () => void;
|
||||
openDesktopAgentList: () => void;
|
||||
closeDesktopAgentList: () => void;
|
||||
toggleDesktopAgentList: () => void;
|
||||
openDesktopFileExplorer: () => void;
|
||||
closeDesktopFileExplorer: () => void;
|
||||
toggleDesktopFileExplorer: () => void;
|
||||
toggleDesktopSidebars: () => 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;
|
||||
@@ -118,13 +128,7 @@ function clampExplorerFilesSplitRatio(ratio: number): number {
|
||||
return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO);
|
||||
}
|
||||
|
||||
function resolveExplorerTabFromCheckout(
|
||||
state: PanelState,
|
||||
checkout: ExplorerCheckoutContext | null,
|
||||
): ExplorerTab | null {
|
||||
if (!checkout) {
|
||||
return null;
|
||||
}
|
||||
function resolveExplorerTabFromCheckout(state: PanelState, checkout: ExplorerCheckoutContext) {
|
||||
return resolveExplorerTabForCheckout({
|
||||
serverId: checkout.serverId,
|
||||
cwd: checkout.cwd,
|
||||
@@ -133,6 +137,47 @@ function resolveExplorerTabFromCheckout(
|
||||
});
|
||||
}
|
||||
|
||||
function buildOpenFileExplorerPatch(
|
||||
state: PanelState,
|
||||
input: ExplorerPanelIntent,
|
||||
): Partial<PanelState> {
|
||||
const resolvedTab = resolveExplorerTabFromCheckout(state, input.checkout);
|
||||
if (input.isCompact) {
|
||||
return {
|
||||
mobileView: "file-explorer",
|
||||
explorerTab: resolvedTab,
|
||||
};
|
||||
}
|
||||
return {
|
||||
desktop: { ...state.desktop, fileExplorerOpen: true },
|
||||
explorerTab: resolvedTab,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectPanelVisibility(
|
||||
state: PanelState,
|
||||
input: PanelLayoutInput,
|
||||
): PanelVisibilityState {
|
||||
if (input.isCompact) {
|
||||
return {
|
||||
isAgentListOpen: state.mobileView === "agent-list",
|
||||
isFileExplorerOpen: state.mobileView === "file-explorer",
|
||||
};
|
||||
}
|
||||
return {
|
||||
isAgentListOpen: state.desktop.agentListOpen,
|
||||
isFileExplorerOpen: state.desktop.fileExplorerOpen,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectIsAgentListOpen(state: PanelState, input: PanelLayoutInput): boolean {
|
||||
return selectPanelVisibility(state, input).isAgentListOpen;
|
||||
}
|
||||
|
||||
export function selectIsFileExplorerOpen(state: PanelState, input: PanelLayoutInput): boolean {
|
||||
return selectPanelVisibility(state, input).isFileExplorerOpen;
|
||||
}
|
||||
|
||||
const DEFAULT_DESKTOP_OPEN = isWeb;
|
||||
|
||||
export const usePanelStore = create<PanelState>()(
|
||||
@@ -179,32 +224,11 @@ export const usePanelStore = create<PanelState>()(
|
||||
return { mobileView: "agent-list" as const };
|
||||
}),
|
||||
|
||||
showMobileFileExplorer: () =>
|
||||
set((state) => {
|
||||
const resolvedTab = resolveExplorerTabFromCheckout(state, getActiveExplorerCheckout());
|
||||
return {
|
||||
mobileView: "file-explorer" as const,
|
||||
...(resolvedTab ? { explorerTab: resolvedTab } : {}),
|
||||
};
|
||||
}),
|
||||
|
||||
toggleMobileAgentList: () =>
|
||||
set((state) => ({
|
||||
mobileView: state.mobileView === "agent-list" ? "agent" : "agent-list",
|
||||
})),
|
||||
|
||||
toggleMobileFileExplorer: () =>
|
||||
set((state) => {
|
||||
if (state.mobileView === "file-explorer") {
|
||||
return { mobileView: "agent" as const };
|
||||
}
|
||||
const resolvedTab = resolveExplorerTabFromCheckout(state, getActiveExplorerCheckout());
|
||||
return {
|
||||
mobileView: "file-explorer" as const,
|
||||
...(resolvedTab ? { explorerTab: resolvedTab } : {}),
|
||||
};
|
||||
}),
|
||||
|
||||
openDesktopAgentList: () =>
|
||||
set((state) => {
|
||||
if (state.desktop.agentListOpen) {
|
||||
@@ -226,15 +250,6 @@ export const usePanelStore = create<PanelState>()(
|
||||
desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen },
|
||||
})),
|
||||
|
||||
openDesktopFileExplorer: () =>
|
||||
set((state) => {
|
||||
const resolvedTab = resolveExplorerTabFromCheckout(state, getActiveExplorerCheckout());
|
||||
return {
|
||||
desktop: { ...state.desktop, fileExplorerOpen: true },
|
||||
...(resolvedTab ? { explorerTab: resolvedTab } : {}),
|
||||
};
|
||||
}),
|
||||
|
||||
closeDesktopFileExplorer: () =>
|
||||
set((state) => {
|
||||
if (!state.desktop.fileExplorerOpen) {
|
||||
@@ -243,31 +258,51 @@ export const usePanelStore = create<PanelState>()(
|
||||
return { desktop: { ...state.desktop, fileExplorerOpen: false } };
|
||||
}),
|
||||
|
||||
toggleDesktopFileExplorer: () =>
|
||||
openAgentListForLayout: ({ isCompact }) =>
|
||||
set((state) => {
|
||||
const willOpen = !state.desktop.fileExplorerOpen;
|
||||
const resolvedTab = willOpen
|
||||
? resolveExplorerTabFromCheckout(state, getActiveExplorerCheckout())
|
||||
: null;
|
||||
if (isCompact) {
|
||||
return state.mobileView === "agent-list"
|
||||
? state
|
||||
: { mobileView: "agent-list" as const };
|
||||
}
|
||||
return state.desktop.agentListOpen
|
||||
? state
|
||||
: { desktop: { ...state.desktop, agentListOpen: true } };
|
||||
}),
|
||||
|
||||
closeAgentListForLayout: ({ isCompact }) =>
|
||||
set((state) => {
|
||||
if (isCompact) {
|
||||
return state.mobileView === "agent" ? state : { mobileView: "agent" as const };
|
||||
}
|
||||
return state.desktop.agentListOpen
|
||||
? { desktop: { ...state.desktop, agentListOpen: false } }
|
||||
: state;
|
||||
}),
|
||||
|
||||
toggleAgentListForLayout: ({ isCompact }) =>
|
||||
set((state) => {
|
||||
if (isCompact) {
|
||||
return { mobileView: state.mobileView === "agent-list" ? "agent" : "agent-list" };
|
||||
}
|
||||
return {
|
||||
desktop: { ...state.desktop, fileExplorerOpen: willOpen },
|
||||
...(resolvedTab ? { explorerTab: resolvedTab } : {}),
|
||||
desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen },
|
||||
};
|
||||
}),
|
||||
|
||||
toggleDesktopSidebars: () =>
|
||||
openFileExplorerForCheckout: (input) =>
|
||||
set((state) => buildOpenFileExplorerPatch(state, input)),
|
||||
|
||||
toggleFileExplorerForCheckout: (input) =>
|
||||
set((state) => {
|
||||
const anyOpen = state.desktop.agentListOpen || state.desktop.fileExplorerOpen;
|
||||
if (anyOpen) {
|
||||
return {
|
||||
desktop: { ...state.desktop, agentListOpen: false, fileExplorerOpen: false },
|
||||
};
|
||||
const isOpen = selectIsFileExplorerOpen(state, input);
|
||||
if (!isOpen) {
|
||||
return buildOpenFileExplorerPatch(state, input);
|
||||
}
|
||||
const resolvedTab = resolveExplorerTabFromCheckout(state, getActiveExplorerCheckout());
|
||||
return {
|
||||
desktop: { ...state.desktop, agentListOpen: true, fileExplorerOpen: true },
|
||||
...(resolvedTab ? { explorerTab: resolvedTab } : {}),
|
||||
};
|
||||
if (input.isCompact) {
|
||||
return { mobileView: "agent" as const };
|
||||
}
|
||||
return { desktop: { ...state.desktop, fileExplorerOpen: false } };
|
||||
}),
|
||||
|
||||
setExplorerTab: (tab) => set({ explorerTab: tab }),
|
||||
@@ -434,54 +469,48 @@ export const usePanelStore = create<PanelState>()(
|
||||
* @param isMobile - Whether the current breakpoint is mobile
|
||||
*/
|
||||
export function usePanelState(isMobile: boolean) {
|
||||
const store = usePanelStore();
|
||||
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);
|
||||
|
||||
if (isMobile) {
|
||||
return {
|
||||
isAgentListOpen: store.mobileView === "agent-list",
|
||||
isFileExplorerOpen: store.mobileView === "file-explorer",
|
||||
openAgentList: store.showMobileAgentList,
|
||||
openFileExplorer: store.showMobileFileExplorer,
|
||||
closeAgentList: store.showMobileAgent,
|
||||
closeFileExplorer: store.showMobileAgent,
|
||||
toggleAgentList: store.toggleMobileAgentList,
|
||||
toggleFileExplorer: store.toggleMobileFileExplorer,
|
||||
// Explorer settings
|
||||
explorerTab: store.explorerTab,
|
||||
explorerTabByCheckout: store.explorerTabByCheckout,
|
||||
explorerWidth: store.explorerWidth,
|
||||
explorerSortOption: store.explorerSortOption,
|
||||
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
|
||||
setExplorerTab: store.setExplorerTab,
|
||||
setExplorerTabForCheckout: store.setExplorerTabForCheckout,
|
||||
activateExplorerTabForCheckout: store.activateExplorerTabForCheckout,
|
||||
setExplorerWidth: store.setExplorerWidth,
|
||||
setExplorerSortOption: store.setExplorerSortOption,
|
||||
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,
|
||||
};
|
||||
}
|
||||
|
||||
// Desktop: independent toggles
|
||||
return {
|
||||
isAgentListOpen: store.desktop.agentListOpen,
|
||||
isFileExplorerOpen: store.desktop.fileExplorerOpen,
|
||||
openAgentList: store.openDesktopAgentList,
|
||||
openFileExplorer: store.openDesktopFileExplorer,
|
||||
closeAgentList: store.closeDesktopAgentList,
|
||||
closeFileExplorer: store.closeDesktopFileExplorer,
|
||||
toggleAgentList: store.toggleDesktopAgentList,
|
||||
toggleFileExplorer: store.toggleDesktopFileExplorer,
|
||||
// Explorer settings
|
||||
explorerTab: store.explorerTab,
|
||||
explorerTabByCheckout: store.explorerTabByCheckout,
|
||||
explorerWidth: store.explorerWidth,
|
||||
explorerSortOption: store.explorerSortOption,
|
||||
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
|
||||
setExplorerTab: store.setExplorerTab,
|
||||
setExplorerTabForCheckout: store.setExplorerTabForCheckout,
|
||||
activateExplorerTabForCheckout: store.activateExplorerTabForCheckout,
|
||||
setExplorerWidth: store.setExplorerWidth,
|
||||
setExplorerSortOption: store.setExplorerSortOption,
|
||||
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user