diff --git a/.oxlintrc.json b/.oxlintrc.json index 90869a9cc..269097aab 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -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", diff --git a/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts b/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts new file mode 100644 index 000000000..ddadeb452 --- /dev/null +++ b/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts @@ -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 { + 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 { + 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(); + } + }); +}); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 0c1eac043..df84ea4f4 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -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>( 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 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({ diff --git a/packages/app/src/components/compact-explorer-sidebar-host.tsx b/packages/app/src/components/compact-explorer-sidebar-host.tsx index 6c5db7774..c22a4b3a2 100644 --- a/packages/app/src/components/compact-explorer-sidebar-host.tsx +++ b/packages/app/src/components/compact-explorer-sidebar-host.tsx @@ -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) => { diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/compact-explorer-sidebar.tsx similarity index 65% rename from packages/app/src/components/explorer-sidebar.tsx rename to packages/app/src/components/compact-explorer-sidebar.tsx index e3ad0e2fa..68b5d99c8 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/compact-explorer-sidebar.tsx @@ -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 ( - - - - - - - - ); -} - 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({ )} - {!hasRightWindowControls && ( - - {({ hovered, pressed }) => ( - - )} - - )} + + {({ hovered, pressed }) => ( + + )} + @@ -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, diff --git a/packages/app/src/components/desktop-sidebar-layout.test.ts b/packages/app/src/components/desktop-sidebar-layout.test.ts index f307ccffd..87b5e9583 100644 --- a/packages/app/src/components/desktop-sidebar-layout.test.ts +++ b/packages/app/src/components/desktop-sidebar-layout.test.ts @@ -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); }); }); diff --git a/packages/app/src/components/desktop-sidebar-layout.ts b/packages/app/src/components/desktop-sidebar-layout.ts index b2d910120..ff58bc802 100644 --- a/packages/app/src/components/desktop-sidebar-layout.ts +++ b/packages/app/src/components/desktop-sidebar-layout.ts @@ -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: { diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 332f8c3fb..ce980f14a 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -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 { diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index bd7739cca..cf0ff2267 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -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 } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 3f01e5786..040ff0b97 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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( diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index 0eedd14d2..aa469cda0 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -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) { diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 169bcc0bb..c7c1cb268 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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, - "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 ( - - - - {children} - - - - - - {showExplorerSidebar && workspaceRoot ? ( - - - - ) : null} - - ); -} - 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, - 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(() => { @@ -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} /> - - {workspaceCenterColumn} - + + + {workspaceCenterColumn} + + + { }); }); + // 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", diff --git a/packages/app/src/stores/panel-store/index.ts b/packages/app/src/stores/panel-store/index.ts index dd19cffc4..2570b54a7 100644 --- a/packages/app/src/stores/panel-store/index.ts +++ b/packages/app/src/stores/panel-store/index.ts @@ -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; collapsedFilePathsByWorkspace: Record; 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()( // Desktop defaults based on platform desktop: { agentListOpen: DEFAULT_DESKTOP_OPEN, - fileExplorerOpen: false, focusModeEnabled: false, }, @@ -151,7 +134,6 @@ export const usePanelStore = create()( 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()( 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()( }; }), - 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()( }), })), 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()( }), { 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()( 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()( }, ), ); - -/** - * 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, - }; -} diff --git a/packages/app/src/stores/panel-store/state.test.ts b/packages/app/src/stores/panel-store/state.test.ts index c89d5cbee..929190270 100644 --- a/packages/app/src/stores/panel-store/state.test.ts +++ b/packages/app/src/stores/panel-store/state.test.ts @@ -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 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"); }); }); diff --git a/packages/app/src/stores/panel-store/state.ts b/packages/app/src/stores/panel-store/state.ts index 2ff029298..3310a646c 100644 --- a/packages/app/src/stores/panel-store/state.ts +++ b/packages/app/src/stores/panel-store/state.ts @@ -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; -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"; diff --git a/packages/app/src/utils/desktop-sidebar-toggle.test.ts b/packages/app/src/utils/desktop-sidebar-toggle.test.ts index 994100582..c547b96b7 100644 --- a/packages/app/src/utils/desktop-sidebar-toggle.test.ts +++ b/packages/app/src/utils/desktop-sidebar-toggle.test.ts @@ -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(); }); }); diff --git a/packages/app/src/utils/desktop-sidebar-toggle.ts b/packages/app/src/utils/desktop-sidebar-toggle.ts index fe186b887..90043ec38 100644 --- a/packages/app/src/utils/desktop-sidebar-toggle.ts +++ b/packages/app/src/utils/desktop-sidebar-toggle.ts @@ -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; } diff --git a/packages/app/src/workspace-tabs/explorer-surface.test.ts b/packages/app/src/workspace-tabs/explorer-surface.test.ts new file mode 100644 index 000000000..8a2ba97db --- /dev/null +++ b/packages/app/src/workspace-tabs/explorer-surface.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@react-native-async-storage/async-storage", () => { + const storage = new Map(); + 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()), + 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); + }); +}); diff --git a/packages/app/src/workspace-tabs/explorer-surface.ts b/packages/app/src/workspace-tabs/explorer-surface.ts new file mode 100644 index 000000000..b6a5c1208 --- /dev/null +++ b/packages/app/src/workspace-tabs/explorer-surface.ts @@ -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 = { + 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; + +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; +}