mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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
-1
@@ -207,11 +207,11 @@
|
||||
"packages/app/src/desktop/browser/pane/index.tsx",
|
||||
"packages/app/src/desktop/browser/pane/index.web.tsx",
|
||||
"packages/app/src/components/command-center.tsx",
|
||||
"packages/app/src/components/compact-explorer-sidebar.tsx",
|
||||
"packages/app/src/components/context-window-meter.tsx",
|
||||
"packages/app/src/components/dictation-controls.tsx",
|
||||
"packages/app/src/components/download-toast.tsx",
|
||||
"packages/app/src/components/draggable-list.native.tsx",
|
||||
"packages/app/src/components/explorer-sidebar.tsx",
|
||||
"packages/app/src/components/headers/back-header.tsx",
|
||||
"packages/app/src/components/headers/menu-header.tsx",
|
||||
"packages/app/src/components/headers/screen-header.tsx",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// Reported on 0.5.0-beta.1 desktop: after upgrading, the old docked explorer
|
||||
// sidebar and the new explorer pane were both visible, and on Windows the
|
||||
// sidebar had no close button because the window controls own that corner.
|
||||
//
|
||||
// The sidebar rendered off a persisted `desktop.fileExplorerOpen` flag that no
|
||||
// migration cleared and that the rerouted explorer toggle no longer wrote. The
|
||||
// explorer is now one surface per layout, so that flag must not resurrect it.
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function visible(page: Page, testId: string): Locator {
|
||||
return page.getByTestId(testId).filter({ visible: true });
|
||||
}
|
||||
|
||||
function readPersistedPanelState(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => localStorage.getItem("panel-state"));
|
||||
}
|
||||
|
||||
/** Only the docked sidebar ever rendered `explorer-header` on a wide layout. */
|
||||
function dockedSidebar(page: Page): Locator {
|
||||
return visible(page, "explorer-header");
|
||||
}
|
||||
|
||||
/** The explorer pane is the tab row carrying the Changes tab. */
|
||||
function explorerTabRow(page: Page): Locator {
|
||||
return visible(page, "workspace-tabs-row").filter({
|
||||
has: page.getByTestId("workspace-tab-working_diff"),
|
||||
});
|
||||
}
|
||||
|
||||
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
|
||||
async function seedRetiredSidebarState(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"panel-state",
|
||||
JSON.stringify({
|
||||
version: 14,
|
||||
state: {
|
||||
desktop: { agentListOpen: true, fileExplorerOpen: true, focusModeEnabled: false },
|
||||
explorerTab: "changes",
|
||||
explorerTabByCheckout: {},
|
||||
expandedPathsByWorkspace: {},
|
||||
diffCollapsedFoldersByWorkspace: {},
|
||||
collapsedFilePathsByWorkspace: {},
|
||||
sidebarWidth: 340,
|
||||
explorerWidth: 400,
|
||||
explorerSortOption: "name",
|
||||
explorerShowHiddenFiles: true,
|
||||
treeRailWidth: 320,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("explorer surface after upgrading from the docked sidebar", () => {
|
||||
test("a persisted fileExplorerOpen flag no longer renders a second explorer", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-surface-upgrade-" });
|
||||
|
||||
try {
|
||||
await seedRetiredSidebarState(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
await test.step("no docked sidebar, on load or after toggling", async () => {
|
||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
||||
|
||||
const toggle = visible(page, "workspace-explorer-toggle").first();
|
||||
await toggle.click();
|
||||
await expect(explorerTabRow(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
||||
|
||||
await toggle.click();
|
||||
await expect(explorerTabRow(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("the retired keys are migrated out of storage", async () => {
|
||||
const persisted = await readPersistedPanelState(page);
|
||||
expect(persisted).not.toBeNull();
|
||||
expect(persisted).not.toContain("fileExplorerOpen");
|
||||
expect(persisted).not.toContain("explorerWidth");
|
||||
// Everything else the user had must survive the migration.
|
||||
expect(persisted).toContain('"sidebarWidth":340');
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("toggle-both-sidebars closes and restores the agent list and the explorer", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-surface-both-" });
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
const agentList = visible(page, "sidebar-sessions");
|
||||
const toggle = visible(page, "workspace-explorer-toggle").first();
|
||||
|
||||
await test.step("open the explorer so both sides are showing", async () => {
|
||||
await toggle.click();
|
||||
await expect(explorerTabRow(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(agentList).toHaveCount(1);
|
||||
});
|
||||
|
||||
await test.step("the shortcut collapses both", async () => {
|
||||
await page.keyboard.press(`${modifier}+.`);
|
||||
await expect(explorerTabRow(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(agentList).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("the shortcut restores both", async () => {
|
||||
await page.keyboard.press(`${modifier}+.`);
|
||||
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerTabRow(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,8 @@ import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { Check, ChevronDown, X } from "lucide-react-native";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { openExplorerSurface } from "@/workspace-tabs/explorer-surface";
|
||||
import {
|
||||
AssistantMessage,
|
||||
SpeakMessage,
|
||||
@@ -339,8 +340,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? context.serverId ?? "";
|
||||
@@ -445,15 +444,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
setCurrentPath: false,
|
||||
});
|
||||
|
||||
const checkout = {
|
||||
serverId: resolvedServerId,
|
||||
cwd: context.cwd,
|
||||
isGit: context.projectPlacement?.checkout?.isGit ?? true,
|
||||
};
|
||||
setExplorerTabForCheckout({ ...checkout, tab: "files" });
|
||||
openFileExplorerForCheckout({
|
||||
openExplorerSurface({
|
||||
isCompact: isMobile,
|
||||
checkout,
|
||||
workspaceKey: buildWorkspaceTabPersistenceKey({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: context.workspaceId ?? "",
|
||||
}),
|
||||
checkout: {
|
||||
serverId: resolvedServerId,
|
||||
cwd: context.cwd,
|
||||
isGit: context.projectPlacement?.checkout?.isGit ?? true,
|
||||
},
|
||||
view: "files",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -117,7 +117,6 @@ import { getNextThemePreference, THEME_TO_UNISTYLES } from "@/styles/theme";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
import {
|
||||
useHasWindowChromeObstruction,
|
||||
WindowChromeProvider,
|
||||
@@ -463,15 +462,10 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
||||
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
|
||||
const openDesktopAgentList = usePanelStore((state) => state.openDesktopAgentList);
|
||||
const closeDesktopAgentList = usePanelStore((state) => state.closeDesktopAgentList);
|
||||
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||
const exitFocusMode = usePanelStore((state) => state.exitFocusMode);
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const isDesktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
|
||||
const cycleTheme = useCallback(() => {
|
||||
@@ -487,25 +481,16 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const hasMountedDesktopSidebar = useLatchedBoolean(chromeEnabled);
|
||||
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
|
||||
const toggleDesktopSidebars = useCallback(() => {
|
||||
const { desktop } = usePanelStore.getState();
|
||||
toggleDesktopSidebarsWithCheckoutIntent({
|
||||
isAgentListOpen: desktop.agentListOpen,
|
||||
isFileExplorerOpen: desktop.fileExplorerOpen,
|
||||
openAgentList: openDesktopAgentList,
|
||||
closeAgentList: closeDesktopAgentList,
|
||||
closeFileExplorer: closeDesktopFileExplorer,
|
||||
toggleFocusedFileExplorer: () =>
|
||||
keyboardActionDispatcher.dispatch({
|
||||
id: "sidebar.toggle.right",
|
||||
scope: "sidebar",
|
||||
}),
|
||||
});
|
||||
}, [
|
||||
closeDesktopAgentList,
|
||||
closeDesktopFileExplorer,
|
||||
keyboardActionDispatcher,
|
||||
openDesktopAgentList,
|
||||
]);
|
||||
// The focused workspace owns its layout key, its checkout, and therefore the
|
||||
// only correct answer to "is the explorer open". Let it decide when there is
|
||||
// one: the pathname alone cannot identify the active workspace, because
|
||||
// desktop cold-starts at "/" and restores the workspace from route params.
|
||||
if (keyboardActionDispatcher.dispatch({ id: "sidebar.toggle.both", scope: "sidebar" })) {
|
||||
return;
|
||||
}
|
||||
// Off a workspace route there is no explorer — only the agent list.
|
||||
toggleAgentList();
|
||||
}, [keyboardActionDispatcher, toggleAgentList]);
|
||||
// TODO: stop matching pathname here as a branch. `chromeEnabled` should not
|
||||
// conflate workspace/project-specific chrome (sidebar, mobile gesture) with
|
||||
// global concerns like keyboard shortcuts. Split those out so settings (and
|
||||
@@ -527,9 +512,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
|
||||
const appContentMinimumWidth = resolveDesktopAppContentMinimum({
|
||||
isSettingsRoute: pathname.includes("/settings"),
|
||||
isWorkspaceExplorerOpen: isWorkspaceRoute && isDesktopFileExplorerOpen,
|
||||
requestedExplorerWidth: explorerWidth,
|
||||
viewportWidth,
|
||||
});
|
||||
const desktopSidebarMounted = hasMountedDesktopSidebar && !isWorkspaceFocusModeEnabled;
|
||||
const desktopSidebarVisible = resolveDesktopSidebarVisibility({
|
||||
|
||||
@@ -3,10 +3,10 @@ import { View } from "react-native";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { CompactExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { CompactExplorerSidebar } from "@/components/compact-explorer-sidebar";
|
||||
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||
@@ -46,9 +46,7 @@ function useActiveCompactExplorerSidebarModel(
|
||||
): CompactExplorerSidebarHostModel | null {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
|
||||
const isExplorerOpen = usePanelStore((state) =>
|
||||
selectIsFileExplorerOpen(state, { isCompact: true }),
|
||||
);
|
||||
const isExplorerOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const client = useHostRuntimeClient(selection?.serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
|
||||
@@ -99,7 +97,7 @@ interface CompactExplorerSidebarHostProps {
|
||||
|
||||
export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) {
|
||||
const model = useActiveCompactExplorerSidebarModel(enabled);
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const openWorkspaceTabInFocusedPane = useWorkspaceLayoutStore(
|
||||
(state) => state.openTabInFocusedPane,
|
||||
@@ -110,15 +108,12 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
|
||||
if (!model?.workspaceRoot) {
|
||||
return;
|
||||
}
|
||||
openFileExplorerForCheckout({
|
||||
isCompact: true,
|
||||
checkout: {
|
||||
serverId: model.serverId,
|
||||
cwd: model.workspaceRoot,
|
||||
isGit: model.isGit,
|
||||
},
|
||||
openCompactFileExplorer({
|
||||
serverId: model.serverId,
|
||||
cwd: model.workspaceRoot,
|
||||
isGit: model.isGit,
|
||||
});
|
||||
}, [model, openFileExplorerForCheckout]);
|
||||
}, [model, openCompactFileExplorer]);
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
(filePath: string) => {
|
||||
|
||||
+26
-173
@@ -1,20 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
useWindowDimensions,
|
||||
StyleSheet as RNStyleSheet,
|
||||
} from "react-native";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatPrTabLabel, PullRequestTabIcon } from "@/git/pull-request-panel";
|
||||
import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store";
|
||||
import {
|
||||
usePanelStore,
|
||||
selectIsCompactFileExplorerOpen,
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import {
|
||||
@@ -26,16 +21,10 @@ import { ChangesSurface } from "@/git/diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { shouldUseCompactExplorerKeyboardPadding } from "@/hooks/keyboard-shift-policy";
|
||||
import { useHasOwnedWindowChromeObstruction, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { RetainedPanel, RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
|
||||
import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||
} from "@/components/sidebar-resize-handle-layout";
|
||||
import { usePullRequestPanelAvailability } from "@/panels/pull-request-availability";
|
||||
import { PullRequestContent } from "@/panels/pull-request";
|
||||
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
|
||||
@@ -81,7 +70,7 @@ export function CompactExplorerSidebar({
|
||||
}: ExplorerSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: true }));
|
||||
const isOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
@@ -149,123 +138,6 @@ export function CompactExplorerSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
export function ExplorerSidebar({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
onOpenFile,
|
||||
}: ExplorerSidebarProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
|
||||
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: false }));
|
||||
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
});
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const visibleExplorerWidth = resolveDesktopExplorerWidth({
|
||||
requestedWidth: explorerWidth,
|
||||
viewportWidth,
|
||||
});
|
||||
const startWidthRef = useRef(visibleExplorerWidth);
|
||||
const resizeWidth = useSharedValue(visibleExplorerWidth);
|
||||
const [resizePressed, setResizePressed] = useState(false);
|
||||
const showResizeGrip = useCallback(() => setResizePressed(true), []);
|
||||
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
resizeWidth.value = visibleExplorerWidth;
|
||||
}, [resizeWidth, visibleExplorerWidth]);
|
||||
|
||||
const handleDesktopClose = useCallback(() => {
|
||||
logExplorerSidebar("handleClose", {
|
||||
reason: "desktop-close-button",
|
||||
isOpen,
|
||||
});
|
||||
closeDesktopFileExplorer();
|
||||
}, [closeDesktopFileExplorer, isOpen]);
|
||||
|
||||
const resizeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.enabled(true)
|
||||
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
||||
.onBegin(() => {
|
||||
scheduleOnRN(showResizeGrip);
|
||||
})
|
||||
// See the left sidebar's gesture: horizontal intent only, with the start
|
||||
// width anchored to the activation translation so the threshold is free.
|
||||
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
|
||||
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
|
||||
.onStart((event) => {
|
||||
startWidthRef.current = visibleExplorerWidth + event.translationX;
|
||||
resizeWidth.value = visibleExplorerWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
const newWidth = startWidthRef.current - event.translationX;
|
||||
resizeWidth.value = resolveDesktopExplorerWidth({
|
||||
requestedWidth: newWidth,
|
||||
viewportWidth,
|
||||
});
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(setExplorerWidth)(resizeWidth.value);
|
||||
})
|
||||
.onFinalize(() => {
|
||||
scheduleOnRN(hideResizeGrip);
|
||||
}),
|
||||
[
|
||||
hideResizeGrip,
|
||||
resizeWidth,
|
||||
setExplorerWidth,
|
||||
showResizeGrip,
|
||||
viewportWidth,
|
||||
visibleExplorerWidth,
|
||||
],
|
||||
);
|
||||
|
||||
const resizeAnimatedStyle = useAnimatedStyle(() => ({
|
||||
width: resizeWidth.value,
|
||||
}));
|
||||
const desktopSidebarStyle = useMemo(
|
||||
() => [explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }],
|
||||
[resizeAnimatedStyle, insets.top],
|
||||
);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={desktopSidebarStyle}>
|
||||
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
|
||||
<SidebarResizeHandle
|
||||
edge="left"
|
||||
gesture={resizeGesture}
|
||||
pressed={resizePressed}
|
||||
testID="explorer-sidebar-resize-handle"
|
||||
/>
|
||||
|
||||
<ExplorerSidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleDesktopClose}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExplorerTabButtonProps {
|
||||
tab: ExplorerTab;
|
||||
active: boolean;
|
||||
@@ -319,7 +191,6 @@ function ExplorerSidebarContent({
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right");
|
||||
const { prPane, showPullRequest: showPrTab } = usePullRequestPanelAvailability({
|
||||
serverId,
|
||||
cwd: workspaceRoot,
|
||||
@@ -389,27 +260,23 @@ function ExplorerSidebarContent({
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.headerRightSection}>
|
||||
{!hasRightWindowControls && (
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={styles.closeButton}
|
||||
testID="explorer-close"
|
||||
nativeID="explorer-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("workspace.tabs.explorer.close")}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<X
|
||||
size={18}
|
||||
color={
|
||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={styles.closeButton}
|
||||
testID="explorer-close"
|
||||
nativeID="explorer-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("workspace.tabs.explorer.close")}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<X
|
||||
size={18}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</WindowChromeSafeArea>
|
||||
|
||||
@@ -495,21 +362,7 @@ function FilesPane({
|
||||
|
||||
const PrTabContent = PullRequestContent;
|
||||
|
||||
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
|
||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||
// tries to patch the native node that Reanimated also manages.
|
||||
const explorerStaticStyles = RNStyleSheet.create({
|
||||
desktopSidebar: {
|
||||
position: "relative" as const,
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
desktopSidebarBorder: {
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
sidebarContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
canDesktopAppSidebarShare,
|
||||
resolveDesktopAppChromeLayout,
|
||||
resolveDesktopAppContentMinimum,
|
||||
resolveDesktopExplorerWidth,
|
||||
resolveDesktopSidebarVisibility,
|
||||
resolveDesktopSidebarWidth,
|
||||
} from "@/components/desktop-sidebar-layout";
|
||||
@@ -79,18 +78,8 @@ describe("desktop sidebar layout", () => {
|
||||
expect(resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 1440 })).toBe(600);
|
||||
});
|
||||
|
||||
it("keeps a temporarily narrow explorer render-only", () => {
|
||||
expect(resolveDesktopExplorerWidth({ requestedWidth: 400, viewportWidth: 751 })).toBe(351);
|
||||
expect(resolveDesktopExplorerWidth({ requestedWidth: 400, viewportWidth: 1440 })).toBe(400);
|
||||
});
|
||||
|
||||
it("yields app navigation when settings or Explorer need the shell width", () => {
|
||||
const settingsMinimum = resolveDesktopAppContentMinimum({
|
||||
isSettingsRoute: true,
|
||||
isWorkspaceExplorerOpen: false,
|
||||
requestedExplorerWidth: 400,
|
||||
viewportWidth: 751,
|
||||
});
|
||||
it("yields app navigation when settings needs the shell width", () => {
|
||||
const settingsMinimum = resolveDesktopAppContentMinimum({ isSettingsRoute: true });
|
||||
expect(settingsMinimum).toBe(720);
|
||||
expect(
|
||||
canDesktopAppSidebarShare({
|
||||
@@ -99,32 +88,16 @@ describe("desktop sidebar layout", () => {
|
||||
viewportWidth: 751,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
const explorerMinimum = resolveDesktopAppContentMinimum({
|
||||
isSettingsRoute: false,
|
||||
isWorkspaceExplorerOpen: true,
|
||||
requestedExplorerWidth: 400,
|
||||
viewportWidth: 751,
|
||||
});
|
||||
expect(explorerMinimum).toBe(751);
|
||||
it("imposes no content minimum outside settings", () => {
|
||||
expect(resolveDesktopAppContentMinimum({ isSettingsRoute: false })).toBe(0);
|
||||
expect(
|
||||
canDesktopAppSidebarShare({
|
||||
contentMinimumWidth: explorerMinimum,
|
||||
contentMinimumWidth: resolveDesktopAppContentMinimum({ isSettingsRoute: false }),
|
||||
requestedSidebarWidth: 320,
|
||||
viewportWidth: 751,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canDesktopAppSidebarShare({
|
||||
contentMinimumWidth: resolveDesktopAppContentMinimum({
|
||||
isSettingsRoute: false,
|
||||
isWorkspaceExplorerOpen: true,
|
||||
requestedExplorerWidth: 400,
|
||||
viewportWidth: 1120,
|
||||
}),
|
||||
requestedSidebarWidth: 320,
|
||||
viewportWidth: 1120,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { SETTINGS_DESKTOP_SPLIT_MIN_WIDTH } from "@/constants/layout";
|
||||
import {
|
||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
} from "@/stores/panel-store";
|
||||
import { MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
|
||||
|
||||
export const MIN_DESKTOP_CENTER_WIDTH = 400;
|
||||
const MIN_DESKTOP_CENTER_WIDTH = 400;
|
||||
|
||||
export function resolveDesktopSidebarVisibility(input: {
|
||||
chromeEnabled: boolean;
|
||||
@@ -67,32 +62,8 @@ export function resolveDesktopSidebarWidth(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDesktopExplorerWidth(input: {
|
||||
requestedWidth: number;
|
||||
viewportWidth: number;
|
||||
}): number {
|
||||
"worklet";
|
||||
return resolveDesktopPanelWidth({
|
||||
...input,
|
||||
minimumWidth: MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
maximumWidth: MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDesktopAppContentMinimum(input: {
|
||||
isSettingsRoute: boolean;
|
||||
isWorkspaceExplorerOpen: boolean;
|
||||
requestedExplorerWidth: number;
|
||||
viewportWidth: number;
|
||||
}): number {
|
||||
const workspaceMinimum = input.isWorkspaceExplorerOpen
|
||||
? MIN_DESKTOP_CENTER_WIDTH +
|
||||
resolveDesktopExplorerWidth({
|
||||
requestedWidth: input.requestedExplorerWidth,
|
||||
viewportWidth: input.viewportWidth,
|
||||
})
|
||||
: 0;
|
||||
return Math.max(input.isSettingsRoute ? SETTINGS_DESKTOP_SPLIT_MIN_WIDTH : 0, workspaceMinimum);
|
||||
export function resolveDesktopAppContentMinimum(input: { isSettingsRoute: boolean }): number {
|
||||
return input.isSettingsRoute ? SETTINGS_DESKTOP_SPLIT_MIN_WIDTH : 0;
|
||||
}
|
||||
|
||||
export function canDesktopAppSidebarShare(input: {
|
||||
|
||||
@@ -20,7 +20,6 @@ import { resolveTurnPresentation, TURN_LIVENESS_IDLE } from "@/timeline/turn-liv
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
@@ -49,7 +48,11 @@ import {
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
type WorkspaceDraftTabSetup,
|
||||
} from "@/workspace-tabs/model";
|
||||
import { openExplorerSurface } from "@/workspace-tabs/explorer-surface";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
|
||||
@@ -443,28 +446,19 @@ export function WorkspaceDraftAgentTab({
|
||||
const clearWorkspaceAttachments = useWorkspaceAttachmentsStore(
|
||||
(state) => state.clearWorkspaceAttachments,
|
||||
);
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const handleOpenWorkspaceAttachment = useCallback(
|
||||
(attachment: WorkspaceComposerAttachment) => {
|
||||
if (attachment.kind !== "review") {
|
||||
return;
|
||||
}
|
||||
const checkout = {
|
||||
serverId,
|
||||
cwd: attachment.attachment.cwd,
|
||||
isGit: true,
|
||||
};
|
||||
openFileExplorerForCheckout({
|
||||
checkout,
|
||||
openExplorerSurface({
|
||||
isCompact: isCompactFormFactor,
|
||||
});
|
||||
setExplorerTabForCheckout({
|
||||
...checkout,
|
||||
tab: "changes",
|
||||
workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? "" }),
|
||||
checkout: { serverId, cwd: attachment.attachment.cwd, isGit: true },
|
||||
view: "changes",
|
||||
});
|
||||
},
|
||||
[isCompactFormFactor, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
|
||||
[isCompactFormFactor, serverId, workspaceId],
|
||||
);
|
||||
|
||||
const {
|
||||
|
||||
@@ -35,6 +35,7 @@ export type KeyboardActionId =
|
||||
| "workspace.terminal.new"
|
||||
| "workspace.browser.new"
|
||||
| "sidebar.toggle.right"
|
||||
| "sidebar.toggle.both"
|
||||
| "workspace.new"
|
||||
| "workspace.project.pick"
|
||||
| "worktree.new"
|
||||
@@ -76,6 +77,7 @@ export type KeyboardActionDefinition =
|
||||
| { id: "workspace.terminal.new"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.browser.new"; scope: KeyboardActionScope }
|
||||
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
|
||||
| { id: "sidebar.toggle.both"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.new"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.project.pick"; scope: KeyboardActionScope }
|
||||
| { id: "worktree.new"; scope: KeyboardActionScope }
|
||||
|
||||
@@ -82,7 +82,6 @@ import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
|
||||
import { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
selectAgentTurnPresentation,
|
||||
@@ -91,6 +90,7 @@ import {
|
||||
} from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { openExplorerSurface } from "@/workspace-tabs/explorer-surface";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem, TodoEntry } from "@/types/stream";
|
||||
@@ -1587,28 +1587,19 @@ function ActiveAgentComposer({
|
||||
() => [workspaceAttachmentScopeKey],
|
||||
[workspaceAttachmentScopeKey],
|
||||
);
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const handleOpenWorkspaceAttachment = useCallback(
|
||||
(attachment: WorkspaceComposerAttachment) => {
|
||||
if (attachment.kind !== "review") {
|
||||
return;
|
||||
}
|
||||
const checkout = {
|
||||
serverId,
|
||||
cwd: attachment.attachment.cwd,
|
||||
isGit: true,
|
||||
};
|
||||
openFileExplorerForCheckout({
|
||||
checkout,
|
||||
openExplorerSurface({
|
||||
isCompact: isCompactFormFactor,
|
||||
});
|
||||
setExplorerTabForCheckout({
|
||||
...checkout,
|
||||
tab: "changes",
|
||||
workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
||||
checkout: { serverId, cwd: attachment.attachment.cwd, isGit: true },
|
||||
view: "changes",
|
||||
});
|
||||
},
|
||||
[isCompactFormFactor, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
|
||||
[isCompactFormFactor, serverId, workspaceId],
|
||||
);
|
||||
|
||||
const handleClientSlashCommand = useCallback(
|
||||
|
||||
@@ -84,16 +84,13 @@ function TerminalPanel() {
|
||||
}));
|
||||
const workspaceDirectory = workspaceFields?.workspaceDirectory || null;
|
||||
const isGitCheckout = workspaceFields?.isGitCheckout ?? false;
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer);
|
||||
const handleOpenFileExplorer = useCallback(() => {
|
||||
if (!workspaceDirectory) {
|
||||
return;
|
||||
}
|
||||
openFileExplorerForCheckout({
|
||||
isCompact: true,
|
||||
checkout: { serverId, cwd: workspaceDirectory, isGit: isGitCheckout },
|
||||
});
|
||||
}, [isGitCheckout, openFileExplorerForCheckout, serverId, workspaceDirectory]);
|
||||
openCompactFileExplorer({ serverId, cwd: workspaceDirectory, isGit: isGitCheckout });
|
||||
}, [isGitCheckout, openCompactFileExplorer, serverId, workspaceDirectory]);
|
||||
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
|
||||
|
||||
if (!workspaceDirectory) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
@@ -37,26 +36,29 @@ import {
|
||||
FloatingPanelPortalHost,
|
||||
FloatingPanelPortalHostNameProvider,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { SplitContainer } from "@/components/split-container";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import { WindowChromeRegion } from "@/utils/desktop-window";
|
||||
import { WorkspaceActions } from "@/git/workspace-actions";
|
||||
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
||||
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
|
||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { getOrCreateClientId } from "@/utils/client-id";
|
||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
import {
|
||||
isExplorerSurfaceOpen,
|
||||
openWorkspaceTabBeside,
|
||||
openWorkspaceTabBesideInBackground,
|
||||
toggleExplorerSurface,
|
||||
useIsExplorerSurfaceOpen,
|
||||
} from "@/workspace-tabs/explorer-surface";
|
||||
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||
import { traceInstant } from "@/performance/native-trace";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
collectAllTabs,
|
||||
createWorkspaceLayoutWithExplorer,
|
||||
findPaneById,
|
||||
getFocusedBrowserId,
|
||||
resolveExplorerPaneId,
|
||||
type WorkspaceLayout,
|
||||
type WorkspaceTabOpenOptions,
|
||||
useWorkspaceLayoutStore,
|
||||
@@ -1308,54 +1310,6 @@ function shouldShowWorkspaceScreenHeader(input: {
|
||||
return !input.isFocusModeEnabled || input.isMobile;
|
||||
}
|
||||
|
||||
function shouldShowWorkspaceExplorerSidebar(input: {
|
||||
isRouteFocused: boolean;
|
||||
isFocusModeEnabled: boolean;
|
||||
isMobile: boolean;
|
||||
}): boolean {
|
||||
return !input.isMobile && input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
|
||||
}
|
||||
|
||||
interface WorkspaceChromeRowProps extends Omit<
|
||||
ComponentProps<typeof ExplorerSidebar>,
|
||||
"workspaceRoot"
|
||||
> {
|
||||
children: ReactNode;
|
||||
explorerOpen: boolean;
|
||||
portalHostName: string;
|
||||
showExplorerSidebar: boolean;
|
||||
workspaceRoot: string | null;
|
||||
}
|
||||
|
||||
function WorkspaceChromeRow({
|
||||
children,
|
||||
explorerOpen,
|
||||
portalHostName,
|
||||
showExplorerSidebar,
|
||||
workspaceRoot,
|
||||
...explorerProps
|
||||
}: WorkspaceChromeRowProps) {
|
||||
const explorerRendered = showExplorerSidebar && explorerOpen && workspaceRoot !== null;
|
||||
|
||||
return (
|
||||
<View style={styles.threePaneRow}>
|
||||
<WindowChromeRegion corners={explorerRendered ? "top-left" : "both"}>
|
||||
<FloatingPanelPortalHostNameProvider hostName={portalHostName}>
|
||||
{children}
|
||||
</FloatingPanelPortalHostNameProvider>
|
||||
</WindowChromeRegion>
|
||||
|
||||
<FloatingPanelPortalHost name={portalHostName} />
|
||||
|
||||
{showExplorerSidebar && workspaceRoot ? (
|
||||
<WindowChromeRegion corners="top-right">
|
||||
<ExplorerSidebar {...explorerProps} workspaceRoot={workspaceRoot} />
|
||||
</WindowChromeRegion>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
@@ -1371,94 +1325,6 @@ function canObservePullRequest(
|
||||
return isRouteFocused && isGitCheckout && !isCompact && supportsDesktopPaneSplits();
|
||||
}
|
||||
|
||||
interface ToggleWorkspaceExplorerPaneInput {
|
||||
isCompact: boolean;
|
||||
persistenceKey: string | null;
|
||||
checkout: ExplorerCheckoutContext | null;
|
||||
toggleCompact: (input: { isCompact: boolean; checkout: ExplorerCheckoutContext }) => void;
|
||||
}
|
||||
|
||||
function toggleWorkspaceExplorerPane(input: ToggleWorkspaceExplorerPaneInput): void {
|
||||
if (input.isCompact || !supportsDesktopPaneSplits()) {
|
||||
if (input.checkout) {
|
||||
input.toggleCompact({ isCompact: input.isCompact, checkout: input.checkout });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!input.persistenceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const layout =
|
||||
store.layoutByWorkspace[input.persistenceKey] ?? createWorkspaceLayoutWithExplorer();
|
||||
const parentTabId = findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null;
|
||||
const explorerPaneId = resolveExplorerPaneId(
|
||||
layout,
|
||||
store.explorerPaneIdByWorkspace[input.persistenceKey],
|
||||
);
|
||||
const explorerPane = findPaneById(layout.root, explorerPaneId);
|
||||
if (explorerPane) {
|
||||
if (explorerPane.hidden === true) {
|
||||
store.showPane(input.persistenceKey, explorerPane.id);
|
||||
} else {
|
||||
store.hidePane(input.persistenceKey, explorerPane.id);
|
||||
return;
|
||||
}
|
||||
// The companion pane can already hold an unrelated tab (the setup
|
||||
// progress tab auto-opens here) before the user ever toggles it, so
|
||||
// "empty" is the wrong signal for "needs a Changes tab seeded". Check
|
||||
// for the working_diff tab specifically.
|
||||
const layoutTabs = collectAllTabs(layout.root);
|
||||
const hasWorkingDiffTab = explorerPane.tabIds.some(
|
||||
(tabId) => layoutTabs.find((tab) => tab.tabId === tabId)?.target.kind === "working_diff",
|
||||
);
|
||||
if (!hasWorkingDiffTab) {
|
||||
const tabId = parentTabId
|
||||
? store.openChildTabFocused(input.persistenceKey, { kind: "working_diff" }, parentTabId)
|
||||
: store.openTabFocused(input.persistenceKey, { kind: "working_diff" });
|
||||
if (tabId) {
|
||||
store.moveTabToPane(input.persistenceKey, tabId, explorerPane.id);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ensuredPane = store.ensureExplorerPane(input.persistenceKey);
|
||||
if (!ensuredPane) {
|
||||
return;
|
||||
}
|
||||
const tabId = parentTabId
|
||||
? store.openChildTabFocused(input.persistenceKey, { kind: "working_diff" }, parentTabId)
|
||||
: store.openTabFocused(input.persistenceKey, { kind: "working_diff" });
|
||||
if (tabId) {
|
||||
store.moveTabToPane(input.persistenceKey, tabId, ensuredPane.paneId);
|
||||
}
|
||||
}
|
||||
|
||||
function isWorkspaceExplorerPaneOpen(
|
||||
state: ReturnType<typeof useWorkspaceLayoutStore.getState>,
|
||||
persistenceKey: string | null,
|
||||
): boolean {
|
||||
if (!persistenceKey) {
|
||||
return false;
|
||||
}
|
||||
const layout = state.layoutByWorkspace[persistenceKey];
|
||||
const paneId = layout
|
||||
? resolveExplorerPaneId(layout, state.explorerPaneIdByWorkspace[persistenceKey])
|
||||
: null;
|
||||
const pane = paneId && layout ? findPaneById(layout.root, paneId) : null;
|
||||
return Boolean(pane && pane.hidden !== true);
|
||||
}
|
||||
|
||||
function resolveExplorerOpen(
|
||||
isCompact: boolean,
|
||||
compactExplorerOpen: boolean,
|
||||
desktopExplorerOpen: boolean,
|
||||
): boolean {
|
||||
return isCompact ? compactExplorerOpen : desktopExplorerOpen;
|
||||
}
|
||||
|
||||
interface WorkspaceTerminalTabActionsInput {
|
||||
persistenceKey: string | null;
|
||||
openWorkspaceTabFocused: (
|
||||
@@ -1753,12 +1619,6 @@ function WorkspaceScreenContent({
|
||||
enabled: canObservePullRequest(isRouteFocused, isGitCheckout, isMobile),
|
||||
});
|
||||
|
||||
const isCompactExplorerOpen = usePanelStore((state) =>
|
||||
selectIsFileExplorerOpen(state, { isCompact: isMobile }),
|
||||
);
|
||||
const toggleFileExplorerForCheckout = usePanelStore(
|
||||
(state) => state.toggleFileExplorerForCheckout,
|
||||
);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
|
||||
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
|
||||
@@ -1772,14 +1632,10 @@ function WorkspaceScreenContent({
|
||||
};
|
||||
}, [isGitCheckout, normalizedServerId, workspaceDirectory]);
|
||||
|
||||
const isDesktopExplorerOpen = useWorkspaceLayoutStore((state) =>
|
||||
isWorkspaceExplorerPaneOpen(state, persistenceKey),
|
||||
);
|
||||
const isExplorerOpen = resolveExplorerOpen(
|
||||
isMobile,
|
||||
isCompactExplorerOpen,
|
||||
isDesktopExplorerOpen,
|
||||
);
|
||||
const isExplorerOpen = useIsExplorerSurfaceOpen({
|
||||
isCompact: isMobile,
|
||||
workspaceKey: persistenceKey,
|
||||
});
|
||||
|
||||
const explorerToggleAccessibilityState = useMemo(
|
||||
() => ({ expanded: isExplorerOpen }),
|
||||
@@ -1787,20 +1643,20 @@ function WorkspaceScreenContent({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRouteFocused || isWeb || !isExplorerOpen) {
|
||||
// Back dismisses the compact overlay only. On a wide native layout the
|
||||
// explorer is a tab, `showMobileAgent` has no rendered consumer, and
|
||||
// returning true would swallow Back with nothing to show for it.
|
||||
if (!isRouteFocused || isWeb || !isMobile || !isExplorerOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||
if (isExplorerOpen) {
|
||||
showMobileAgent();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
showMobileAgent();
|
||||
return true;
|
||||
});
|
||||
|
||||
return () => handler.remove();
|
||||
}, [isExplorerOpen, isRouteFocused, showMobileAgent]);
|
||||
}, [isExplorerOpen, isMobile, isRouteFocused, showMobileAgent]);
|
||||
|
||||
const workspaceLayout = useWorkspaceLayoutStore((state) =>
|
||||
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
|
||||
@@ -1832,9 +1688,6 @@ function WorkspaceScreenContent({
|
||||
const openWorkspaceTabInExplorerPaneBackground = useWorkspaceLayoutStore(
|
||||
(state) => state.openTabInExplorerPaneBackground,
|
||||
);
|
||||
const openWorkspaceTabInExplorerPaneFocused = useWorkspaceLayoutStore(
|
||||
(state) => state.openTabInExplorerPaneFocused,
|
||||
);
|
||||
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
||||
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
||||
@@ -1845,13 +1698,12 @@ function WorkspaceScreenContent({
|
||||
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
|
||||
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
|
||||
const handleToggleExplorer = useCallback(() => {
|
||||
toggleWorkspaceExplorerPane({
|
||||
toggleExplorerSurface({
|
||||
isCompact: isMobile,
|
||||
persistenceKey,
|
||||
workspaceKey: persistenceKey,
|
||||
checkout: activeExplorerCheckout,
|
||||
toggleCompact: toggleFileExplorerForCheckout,
|
||||
});
|
||||
}, [activeExplorerCheckout, isMobile, persistenceKey, toggleFileExplorerForCheckout]);
|
||||
}, [activeExplorerCheckout, isMobile, persistenceKey]);
|
||||
const paneFocusSuppressedRef = useRef(false);
|
||||
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
|
||||
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
|
||||
@@ -2128,10 +1980,11 @@ function WorkspaceScreenContent({
|
||||
return;
|
||||
}
|
||||
|
||||
const tabId =
|
||||
isMobile || !supportsDesktopPaneSplits()
|
||||
? openWorkspaceTabInBackground(persistenceKey, target)
|
||||
: openWorkspaceTabInExplorerPaneBackground(persistenceKey, target);
|
||||
const tabId = openWorkspaceTabBesideInBackground({
|
||||
isCompact: isMobile,
|
||||
workspaceKey: persistenceKey,
|
||||
target,
|
||||
});
|
||||
if (!tabId) {
|
||||
return;
|
||||
}
|
||||
@@ -2204,26 +2057,6 @@ function WorkspaceScreenContent({
|
||||
workspaceAgentVisibility.activeAgentIds.size,
|
||||
]);
|
||||
|
||||
const handleOpenFileFromExplorer = useCallback(
|
||||
function handleOpenFileFromExplorer(filePath: string) {
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
const location = normalizeWorkspaceFileLocation({ path: filePath });
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
const tabId = openWorkspaceTabInFocusedPane(
|
||||
persistenceKey,
|
||||
createWorkspaceFileTabTarget(location),
|
||||
);
|
||||
if (tabId) {
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
},
|
||||
[navigateToTabId, openWorkspaceTabInFocusedPane, persistenceKey],
|
||||
);
|
||||
|
||||
const handleOpenFileFromChat = useCallback(
|
||||
(location: WorkspaceFileLocation, options?: { parentTabId?: string | null }) => {
|
||||
const normalizedLocation = normalizeWorkspaceFileLocation(location);
|
||||
@@ -2262,24 +2095,25 @@ function WorkspaceScreenContent({
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
if (!persistenceKey || isMobile || !supportsDesktopPaneSplits()) {
|
||||
handleOpenFileFromChat(location, { parentTabId: input.parentTabId });
|
||||
if (isMobile) {
|
||||
showMobileAgent();
|
||||
}
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target: WorkspaceTabTarget = createWorkspaceFileTabTarget(location);
|
||||
const tabId = useWorkspaceLayoutStore
|
||||
.getState()
|
||||
.openTabInExplorerPaneFocused(persistenceKey, {
|
||||
target,
|
||||
parentTabId: input.parentTabId,
|
||||
});
|
||||
const tabId = openWorkspaceTabBeside({
|
||||
isCompact: isMobile,
|
||||
workspaceKey: persistenceKey,
|
||||
target: createWorkspaceFileTabTarget(location),
|
||||
parentTabId: input.parentTabId,
|
||||
});
|
||||
if (tabId) {
|
||||
requestFileNavigation(tabId);
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
},
|
||||
[handleOpenFileFromChat, isMobile, navigateToTabId, persistenceKey, requestFileNavigation],
|
||||
[isMobile, navigateToTabId, persistenceKey, requestFileNavigation, showMobileAgent],
|
||||
);
|
||||
|
||||
const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({
|
||||
@@ -2787,18 +2621,8 @@ function WorkspaceScreenContent({
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
if (isMobile || !supportsDesktopPaneSplits()) {
|
||||
openWorkspaceTabInFocusedPane(persistenceKey, target);
|
||||
return;
|
||||
}
|
||||
openWorkspaceTabInExplorerPaneFocused(persistenceKey, { target });
|
||||
}, [
|
||||
isMobile,
|
||||
normalizedWorkspaceId,
|
||||
openWorkspaceTabInFocusedPane,
|
||||
openWorkspaceTabInExplorerPaneFocused,
|
||||
persistenceKey,
|
||||
]);
|
||||
openWorkspaceTabBeside({ isCompact: isMobile, workspaceKey: persistenceKey, target });
|
||||
}, [isMobile, normalizedWorkspaceId, persistenceKey]);
|
||||
|
||||
const handleBulkCloseTabs = useCallback(
|
||||
async (input: { tabsToClose: WorkspaceTabDescriptor[]; title: string; logLabel: string }) => {
|
||||
@@ -2996,13 +2820,29 @@ function WorkspaceScreenContent({
|
||||
|
||||
const handleWorkspaceSidebarAction = useCallback(
|
||||
(action: KeyboardActionDefinition): boolean => {
|
||||
if (action.id !== "sidebar.toggle.right") {
|
||||
if (action.id === "sidebar.toggle.right") {
|
||||
handleToggleExplorer();
|
||||
return true;
|
||||
}
|
||||
if (action.id !== "sidebar.toggle.both") {
|
||||
return false;
|
||||
}
|
||||
handleToggleExplorer();
|
||||
// This screen owns the layout key and the checkout, so it is the only
|
||||
// place that can read "is the explorer open" correctly.
|
||||
const panel = usePanelStore.getState();
|
||||
toggleDesktopSidebarsWithCheckoutIntent({
|
||||
isAgentListOpen: selectIsAgentListOpen(panel, { isCompact: isMobile }),
|
||||
isExplorerOpen: isExplorerSurfaceOpen({
|
||||
isCompact: isMobile,
|
||||
workspaceKey: persistenceKey,
|
||||
}),
|
||||
openAgentList: () => panel.openAgentListForLayout({ isCompact: isMobile }),
|
||||
closeAgentList: () => panel.closeAgentListForLayout({ isCompact: isMobile }),
|
||||
toggleExplorer: handleToggleExplorer,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[handleToggleExplorer],
|
||||
[handleToggleExplorer, isMobile, persistenceKey],
|
||||
);
|
||||
|
||||
const handleWorkspacePaneAction = useCallback(
|
||||
@@ -3146,7 +2986,7 @@ function WorkspaceScreenContent({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
}),
|
||||
actions: ["sidebar.toggle.right"] as const,
|
||||
actions: ["sidebar.toggle.right", "sidebar.toggle.both"] as const,
|
||||
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
|
||||
priority: 100,
|
||||
isActive: () => true,
|
||||
@@ -3480,10 +3320,6 @@ function WorkspaceScreenContent({
|
||||
() => shouldShowWorkspaceScreenHeader({ isFocusModeEnabled, isMobile }),
|
||||
[isFocusModeEnabled, isMobile],
|
||||
);
|
||||
const showExplorerSidebar = useMemo(
|
||||
() => shouldShowWorkspaceExplorerSidebar({ isRouteFocused, isFocusModeEnabled, isMobile }),
|
||||
[isRouteFocused, isFocusModeEnabled, isMobile],
|
||||
);
|
||||
const createTerminalDisabled = useMemo(
|
||||
() => createTerminalMutation.isPending || pendingTerminalCreateInput !== null,
|
||||
[createTerminalMutation.isPending, pendingTerminalCreateInput],
|
||||
@@ -3695,18 +3531,12 @@ function WorkspaceScreenContent({
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
isRouteFocused={isRouteFocused}
|
||||
/>
|
||||
<WorkspaceChromeRow
|
||||
portalHostName={workspaceFloatingPanelPortalHostName}
|
||||
showExplorerSidebar={showExplorerSidebar}
|
||||
explorerOpen={isExplorerOpen}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
workspaceRoot={workspaceDirectory}
|
||||
isGit={isGitCheckout}
|
||||
onOpenFile={handleOpenFileFromExplorer}
|
||||
>
|
||||
{workspaceCenterColumn}
|
||||
</WorkspaceChromeRow>
|
||||
<View style={styles.threePaneRow}>
|
||||
<FloatingPanelPortalHostNameProvider hostName={workspaceFloatingPanelPortalHostName}>
|
||||
{workspaceCenterColumn}
|
||||
</FloatingPanelPortalHostNameProvider>
|
||||
<FloatingPanelPortalHost name={workspaceFloatingPanelPortalHostName} />
|
||||
</View>
|
||||
<ImportSessionSheet
|
||||
visible={isRouteFocused && isImportSheetVisible}
|
||||
client={client}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -2,48 +2,50 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "./desktop-sidebar-toggle";
|
||||
|
||||
function harness(state: { isAgentListOpen: boolean; isExplorerOpen: boolean }) {
|
||||
const openAgentList = vi.fn();
|
||||
const closeAgentList = vi.fn();
|
||||
const toggleExplorer = vi.fn();
|
||||
const handled = toggleDesktopSidebarsWithCheckoutIntent({
|
||||
...state,
|
||||
openAgentList,
|
||||
closeAgentList,
|
||||
toggleExplorer,
|
||||
});
|
||||
return { handled, openAgentList, closeAgentList, toggleExplorer };
|
||||
}
|
||||
|
||||
describe("toggleDesktopSidebarsWithCheckoutIntent", () => {
|
||||
it("closes both sidebars when either desktop sidebar is open", () => {
|
||||
const openAgentList = vi.fn();
|
||||
const closeAgentList = vi.fn();
|
||||
const closeFileExplorer = vi.fn();
|
||||
const toggleFocusedFileExplorer = vi.fn(() => true);
|
||||
it("closes only what is open when either side is showing", () => {
|
||||
const result = harness({ isAgentListOpen: true, isExplorerOpen: false });
|
||||
|
||||
const handled = toggleDesktopSidebarsWithCheckoutIntent({
|
||||
isAgentListOpen: true,
|
||||
isFileExplorerOpen: false,
|
||||
openAgentList,
|
||||
closeAgentList,
|
||||
closeFileExplorer,
|
||||
toggleFocusedFileExplorer,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(closeAgentList).toHaveBeenCalledTimes(1);
|
||||
expect(closeFileExplorer).toHaveBeenCalledTimes(1);
|
||||
expect(openAgentList).not.toHaveBeenCalled();
|
||||
expect(toggleFocusedFileExplorer).not.toHaveBeenCalled();
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.closeAgentList).toHaveBeenCalledTimes(1);
|
||||
expect(result.openAgentList).not.toHaveBeenCalled();
|
||||
// The explorer is already closed; toggling it would reopen it.
|
||||
expect(result.toggleExplorer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the right sidebar only through the focused checkout-aware handler", () => {
|
||||
const openAgentList = vi.fn();
|
||||
const closeAgentList = vi.fn();
|
||||
const closeFileExplorer = vi.fn();
|
||||
const toggleFocusedFileExplorer = vi.fn(() => false);
|
||||
it("closes the explorer when it is the only side showing", () => {
|
||||
const result = harness({ isAgentListOpen: false, isExplorerOpen: true });
|
||||
|
||||
const handled = toggleDesktopSidebarsWithCheckoutIntent({
|
||||
isAgentListOpen: false,
|
||||
isFileExplorerOpen: false,
|
||||
openAgentList,
|
||||
closeAgentList,
|
||||
closeFileExplorer,
|
||||
toggleFocusedFileExplorer,
|
||||
});
|
||||
expect(result.toggleExplorer).toHaveBeenCalledTimes(1);
|
||||
expect(result.closeAgentList).not.toHaveBeenCalled();
|
||||
expect(result.openAgentList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(openAgentList).toHaveBeenCalledTimes(1);
|
||||
expect(toggleFocusedFileExplorer).toHaveBeenCalledTimes(1);
|
||||
expect(closeAgentList).not.toHaveBeenCalled();
|
||||
expect(closeFileExplorer).not.toHaveBeenCalled();
|
||||
it("closes both when both are showing", () => {
|
||||
const result = harness({ isAgentListOpen: true, isExplorerOpen: true });
|
||||
|
||||
expect(result.closeAgentList).toHaveBeenCalledTimes(1);
|
||||
expect(result.toggleExplorer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("opens both when nothing is showing", () => {
|
||||
const result = harness({ isAgentListOpen: false, isExplorerOpen: false });
|
||||
|
||||
expect(result.openAgentList).toHaveBeenCalledTimes(1);
|
||||
expect(result.toggleExplorer).toHaveBeenCalledTimes(1);
|
||||
expect(result.closeAgentList).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
interface DesktopSidebarToggleInput {
|
||||
isAgentListOpen: boolean;
|
||||
isFileExplorerOpen: boolean;
|
||||
isExplorerOpen: boolean;
|
||||
openAgentList: () => void;
|
||||
closeAgentList: () => void;
|
||||
closeFileExplorer: () => void;
|
||||
toggleFocusedFileExplorer: () => boolean;
|
||||
toggleExplorer: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Toggle both sidebars" collapses the workspace to just its content when
|
||||
* anything is showing, and restores both when nothing is. The explorer only
|
||||
* exposes a toggle, so each side is nudged only when it is on the wrong side
|
||||
* of the intent.
|
||||
*/
|
||||
export function toggleDesktopSidebarsWithCheckoutIntent(input: DesktopSidebarToggleInput): boolean {
|
||||
if (input.isAgentListOpen || input.isFileExplorerOpen) {
|
||||
input.closeAgentList();
|
||||
input.closeFileExplorer();
|
||||
return true;
|
||||
}
|
||||
const shouldOpen = !input.isAgentListOpen && !input.isExplorerOpen;
|
||||
|
||||
input.openAgentList();
|
||||
input.toggleFocusedFileExplorer();
|
||||
if (input.isAgentListOpen !== shouldOpen) {
|
||||
if (shouldOpen) {
|
||||
input.openAgentList();
|
||||
} else {
|
||||
input.closeAgentList();
|
||||
}
|
||||
}
|
||||
if (input.isExplorerOpen !== shouldOpen) {
|
||||
input.toggleExplorer();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
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);
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const paneSplits = vi.hoisted(() => ({ supported: true }));
|
||||
vi.mock("@/constants/layout", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/constants/layout")>()),
|
||||
supportsDesktopPaneSplits: () => paneSplits.supported,
|
||||
}));
|
||||
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
collectAllPanes,
|
||||
collectAllTabs,
|
||||
findPaneById,
|
||||
findPaneContainingTab,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
openExplorerSurface,
|
||||
openWorkspaceTabBeside,
|
||||
isExplorerSurfaceOpen,
|
||||
toggleExplorerSurface,
|
||||
} from "@/workspace-tabs/explorer-surface";
|
||||
|
||||
const WORKSPACE_KEY = "server-1:ws-main";
|
||||
const CHECKOUT = { serverId: "server-1", cwd: "/tmp/repo", isGit: true };
|
||||
|
||||
function layout() {
|
||||
const value = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY];
|
||||
if (!value) throw new Error("workspace layout missing");
|
||||
return value;
|
||||
}
|
||||
|
||||
function tabKinds(): string[] {
|
||||
return collectAllTabs(layout().root).map((tab) => tab.target.kind);
|
||||
}
|
||||
|
||||
function focusedPaneTabKinds(): string[] {
|
||||
const current = layout();
|
||||
const pane = findPaneById(current.root, current.focusedPaneId);
|
||||
const tabs = collectAllTabs(current.root);
|
||||
return (pane?.tabIds ?? []).map(
|
||||
(tabId) => tabs.find((tab) => tab.tabId === tabId)?.target.kind ?? "?",
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
paneSplits.supported = true;
|
||||
useWorkspaceLayoutStore.setState({
|
||||
layoutByWorkspace: {},
|
||||
explorerPaneIdByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
});
|
||||
usePanelStore.setState({ mobilePanel: { target: "agent", revision: 0 } });
|
||||
});
|
||||
|
||||
describe("compact surface", () => {
|
||||
const compact = { isCompact: true, workspaceKey: WORKSPACE_KEY, checkout: CHECKOUT };
|
||||
|
||||
it("opens and closes the overlay without touching the layout", () => {
|
||||
openExplorerSurface({ ...compact, view: "files" });
|
||||
|
||||
expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer");
|
||||
expect(usePanelStore.getState().explorerTab).toBe("files");
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]).toBeUndefined();
|
||||
|
||||
toggleExplorerSurface(compact);
|
||||
expect(usePanelStore.getState().mobilePanel.target).toBe("agent");
|
||||
});
|
||||
|
||||
it("reports the overlay state, not the layout state", () => {
|
||||
expect(isExplorerSurfaceOpen({ isCompact: true, workspaceKey: WORKSPACE_KEY })).toBe(false);
|
||||
toggleExplorerSurface(compact);
|
||||
expect(isExplorerSurfaceOpen({ isCompact: true, workspaceKey: WORKSPACE_KEY })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-compact with splits", () => {
|
||||
const wide = { isCompact: false, workspaceKey: WORKSPACE_KEY, checkout: CHECKOUT };
|
||||
|
||||
it("seeds a Changes tab in a dedicated explorer pane", () => {
|
||||
toggleExplorerSurface(wide);
|
||||
|
||||
expect(tabKinds()).toContain("working_diff");
|
||||
expect(collectAllPanes(layout().root).length).toBeGreaterThan(1);
|
||||
expect(isExplorerSurfaceOpen(wide)).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the pane on the second toggle and restores it on the third", () => {
|
||||
toggleExplorerSurface(wide);
|
||||
toggleExplorerSurface(wide);
|
||||
expect(isExplorerSurfaceOpen(wide)).toBe(false);
|
||||
|
||||
toggleExplorerSurface(wide);
|
||||
expect(isExplorerSurfaceOpen(wide)).toBe(true);
|
||||
// Restoring must not stack a second Changes tab.
|
||||
expect(tabKinds().filter((kind) => kind === "working_diff")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("leaves the compact overlay alone", () => {
|
||||
toggleExplorerSurface(wide);
|
||||
expect(usePanelStore.getState().mobilePanel.target).toBe("agent");
|
||||
});
|
||||
|
||||
it("opens files beside the current tab, in the explorer pane", () => {
|
||||
const tabId = openWorkspaceTabBeside({
|
||||
isCompact: false,
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
target: { kind: "files" },
|
||||
});
|
||||
|
||||
expect(tabId).not.toBeNull();
|
||||
const explorerPaneId =
|
||||
useWorkspaceLayoutStore.getState().explorerPaneIdByWorkspace[WORKSPACE_KEY];
|
||||
expect(findPaneContainingTab(layout().root, tabId!)?.id).toBe(explorerPaneId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-compact without splits", () => {
|
||||
const tablet = { isCompact: false, workspaceKey: WORKSPACE_KEY, checkout: CHECKOUT };
|
||||
|
||||
beforeEach(() => {
|
||||
paneSplits.supported = false;
|
||||
});
|
||||
|
||||
it("opens the explorer as a tab in the focused pane, never a second pane", () => {
|
||||
toggleExplorerSurface(tablet);
|
||||
|
||||
expect(focusedPaneTabKinds()).toContain("working_diff");
|
||||
expect(collectAllPanes(layout().root)).toHaveLength(1);
|
||||
expect(isExplorerSurfaceOpen(tablet)).toBe(true);
|
||||
});
|
||||
|
||||
it("closes the explorer tab on the second toggle", () => {
|
||||
toggleExplorerSurface(tablet);
|
||||
toggleExplorerSurface(tablet);
|
||||
|
||||
expect(tabKinds()).not.toContain("working_diff");
|
||||
expect(isExplorerSurfaceOpen(tablet)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps side-opened files in the focused pane so the tab row still lists them", () => {
|
||||
const tabId = openWorkspaceTabBeside({
|
||||
isCompact: false,
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
target: { kind: "files" },
|
||||
});
|
||||
|
||||
expect(tabId).not.toBeNull();
|
||||
expect(findPaneContainingTab(layout().root, tabId!)?.id).toBe(layout().focusedPaneId);
|
||||
expect(collectAllPanes(layout().root)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { supportsDesktopPaneSplits } from "@/constants/layout";
|
||||
import {
|
||||
usePanelStore,
|
||||
selectIsCompactFileExplorerOpen,
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import type { ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||
import {
|
||||
collectAllTabs,
|
||||
findPaneById,
|
||||
resolveExplorerPaneId,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
/**
|
||||
* The explorer has three surfaces, picked from two facts — is the layout compact,
|
||||
* and can this platform render split panes:
|
||||
*
|
||||
* compact → slide-over overlay, owned by the panel store
|
||||
* non-compact + splits → dedicated right pane, owned by the layout store
|
||||
* non-compact, no splits → ordinary tabs in the focused pane (native tablets)
|
||||
*
|
||||
* Every entry point goes through this module. Nothing else branches on the surface —
|
||||
* the third case is why: a native tablet renders one pane at a time and the tab row
|
||||
* only lists the focused pane's tabs, so anything routed into a separate explorer
|
||||
* pane there would silently disappear.
|
||||
*/
|
||||
|
||||
/** Tab kinds that are explorer views rather than user content. */
|
||||
const EXPLORER_TAB_KINDS = ["working_diff", "files", "pull_request"] as const;
|
||||
type ExplorerTabKind = (typeof EXPLORER_TAB_KINDS)[number];
|
||||
|
||||
const EXPLORER_VIEW_TARGETS: Record<ExplorerTab, WorkspaceTabTarget> = {
|
||||
changes: { kind: "working_diff" },
|
||||
files: { kind: "files" },
|
||||
pr: { kind: "pull_request" },
|
||||
};
|
||||
|
||||
export interface ExplorerSurfaceQuery {
|
||||
isCompact: boolean;
|
||||
workspaceKey: string | null;
|
||||
}
|
||||
|
||||
export interface ExplorerSurfaceInput extends ExplorerSurfaceQuery {
|
||||
checkout: ExplorerCheckoutContext | null;
|
||||
}
|
||||
|
||||
type LayoutState = ReturnType<typeof useWorkspaceLayoutStore.getState>;
|
||||
|
||||
function isExplorerTabKind(kind: string): kind is ExplorerTabKind {
|
||||
return (EXPLORER_TAB_KINDS as readonly string[]).includes(kind);
|
||||
}
|
||||
|
||||
function canUseExplorerPane(isCompact: boolean): boolean {
|
||||
return !isCompact && supportsDesktopPaneSplits();
|
||||
}
|
||||
|
||||
// ─── Explorer pane (non-compact + splits) ──────────────────────────────────
|
||||
|
||||
function findExplorerPane(state: LayoutState, workspaceKey: string) {
|
||||
const layout = state.layoutByWorkspace[workspaceKey];
|
||||
if (!layout) {
|
||||
return null;
|
||||
}
|
||||
const paneId = resolveExplorerPaneId(layout, state.explorerPaneIdByWorkspace[workspaceKey]);
|
||||
return paneId ? findPaneById(layout.root, paneId) : null;
|
||||
}
|
||||
|
||||
function selectIsExplorerPaneOpen(state: LayoutState, workspaceKey: string | null): boolean {
|
||||
if (!workspaceKey) {
|
||||
return false;
|
||||
}
|
||||
const pane = findExplorerPane(state, workspaceKey);
|
||||
return Boolean(pane && pane.hidden !== true);
|
||||
}
|
||||
|
||||
// ─── Explorer tabs (non-compact, no splits) ────────────────────────────────
|
||||
|
||||
/**
|
||||
* The explorer tab the focused pane is currently showing, if any. On a tablet
|
||||
* "the explorer is open" means exactly that — there is no pane to hide.
|
||||
*/
|
||||
function findActiveExplorerTabId(state: LayoutState, workspaceKey: string): string | null {
|
||||
const layout = state.layoutByWorkspace[workspaceKey];
|
||||
if (!layout) {
|
||||
return null;
|
||||
}
|
||||
const focusedTabId = findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null;
|
||||
if (!focusedTabId) {
|
||||
return null;
|
||||
}
|
||||
const tab = collectAllTabs(layout.root).find((candidate) => candidate.tabId === focusedTabId);
|
||||
return tab && isExplorerTabKind(tab.target.kind) ? tab.tabId : null;
|
||||
}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open a tab alongside the current one. With splits it lands in the explorer
|
||||
* pane; without them it opens in the focused pane, because that is the only
|
||||
* pane the user can see.
|
||||
*/
|
||||
export function openWorkspaceTabBeside(input: {
|
||||
isCompact: boolean;
|
||||
workspaceKey: string | null;
|
||||
target: WorkspaceTabTarget;
|
||||
parentTabId?: string | null;
|
||||
}): string | null {
|
||||
if (!input.workspaceKey) {
|
||||
return null;
|
||||
}
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const parentTabId = input.parentTabId?.trim() || null;
|
||||
|
||||
if (canUseExplorerPane(input.isCompact)) {
|
||||
return store.openTabInExplorerPaneFocused(input.workspaceKey, {
|
||||
target: input.target,
|
||||
parentTabId,
|
||||
});
|
||||
}
|
||||
|
||||
return parentTabId
|
||||
? store.openChildTabInFocusedPane(input.workspaceKey, input.target, parentTabId)
|
||||
: store.openTabInFocusedPane(input.workspaceKey, input.target);
|
||||
}
|
||||
|
||||
/** {@link openWorkspaceTabBeside} without moving focus. */
|
||||
export function openWorkspaceTabBesideInBackground(input: {
|
||||
isCompact: boolean;
|
||||
workspaceKey: string | null;
|
||||
target: WorkspaceTabTarget;
|
||||
}): string | null {
|
||||
if (!input.workspaceKey) {
|
||||
return null;
|
||||
}
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
return canUseExplorerPane(input.isCompact)
|
||||
? store.openTabInExplorerPaneBackground(input.workspaceKey, input.target)
|
||||
: store.openTabInBackground(input.workspaceKey, input.target);
|
||||
}
|
||||
|
||||
/** Reveal the explorer showing `view`. */
|
||||
export function openExplorerSurface(input: ExplorerSurfaceInput & { view: ExplorerTab }): void {
|
||||
if (input.isCompact) {
|
||||
if (!input.checkout) {
|
||||
return;
|
||||
}
|
||||
const panel = usePanelStore.getState();
|
||||
panel.setExplorerTabForCheckout({ ...input.checkout, tab: input.view });
|
||||
panel.openCompactFileExplorer(input.checkout);
|
||||
return;
|
||||
}
|
||||
|
||||
openWorkspaceTabBeside({
|
||||
isCompact: input.isCompact,
|
||||
workspaceKey: input.workspaceKey,
|
||||
target: EXPLORER_VIEW_TARGETS[input.view],
|
||||
});
|
||||
}
|
||||
|
||||
/** Show the explorer if it is hidden, hide it if it is showing. */
|
||||
export function toggleExplorerSurface(input: ExplorerSurfaceInput): void {
|
||||
if (input.isCompact) {
|
||||
if (input.checkout) {
|
||||
usePanelStore.getState().toggleCompactFileExplorer(input.checkout);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!input.workspaceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceKey = input.workspaceKey;
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
if (!canUseExplorerPane(input.isCompact)) {
|
||||
const activeExplorerTabId = findActiveExplorerTabId(store, workspaceKey);
|
||||
if (activeExplorerTabId) {
|
||||
store.closeTab(workspaceKey, activeExplorerTabId);
|
||||
return;
|
||||
}
|
||||
store.openTabInFocusedPane(workspaceKey, EXPLORER_VIEW_TARGETS.changes);
|
||||
return;
|
||||
}
|
||||
|
||||
const layout = store.layoutByWorkspace[workspaceKey];
|
||||
const parentTabId = layout
|
||||
? (findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null)
|
||||
: null;
|
||||
const explorerPane = findExplorerPane(store, workspaceKey);
|
||||
|
||||
if (explorerPane) {
|
||||
if (explorerPane.hidden !== true) {
|
||||
store.hidePane(workspaceKey, explorerPane.id);
|
||||
return;
|
||||
}
|
||||
store.showPane(workspaceKey, explorerPane.id);
|
||||
// The companion pane can already hold an unrelated tab (the setup progress
|
||||
// tab auto-opens here) before the user ever toggles it, so "empty" is the
|
||||
// wrong signal for "needs a Changes tab seeded". Check for it specifically.
|
||||
const layoutTabs = layout ? collectAllTabs(layout.root) : [];
|
||||
const hasChangesTab = explorerPane.tabIds.some(
|
||||
(tabId) => layoutTabs.find((tab) => tab.tabId === tabId)?.target.kind === "working_diff",
|
||||
);
|
||||
if (hasChangesTab) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
store.openTabInExplorerPaneFocused(workspaceKey, {
|
||||
target: EXPLORER_VIEW_TARGETS.changes,
|
||||
parentTabId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Reactive "is the explorer showing" for the surface this layout uses. */
|
||||
export function useIsExplorerSurfaceOpen(input: ExplorerSurfaceQuery): boolean {
|
||||
const compactOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
||||
const paneOpen = useWorkspaceLayoutStore((state) =>
|
||||
input.isCompact ? false : isExplorerOpenForLayout(state, input.workspaceKey),
|
||||
);
|
||||
return input.isCompact ? compactOpen : paneOpen;
|
||||
}
|
||||
|
||||
/** Imperative counterpart of {@link useIsExplorerSurfaceOpen}. */
|
||||
export function isExplorerSurfaceOpen(input: ExplorerSurfaceQuery): boolean {
|
||||
if (input.isCompact) {
|
||||
return selectIsCompactFileExplorerOpen(usePanelStore.getState());
|
||||
}
|
||||
return isExplorerOpenForLayout(useWorkspaceLayoutStore.getState(), input.workspaceKey);
|
||||
}
|
||||
|
||||
function isExplorerOpenForLayout(state: LayoutState, workspaceKey: string | null): boolean {
|
||||
if (!workspaceKey) {
|
||||
return false;
|
||||
}
|
||||
return supportsDesktopPaneSplits()
|
||||
? selectIsExplorerPaneOpen(state, workspaceKey)
|
||||
: findActiveExplorerTabId(state, workspaceKey) !== null;
|
||||
}
|
||||
Reference in New Issue
Block a user