From c03e7b82b42e7b2ce6c2d65e96c3b5873792e687 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 23 Jun 2026 16:10:37 +0800 Subject: [PATCH] Keep compact file explorer visible while open (#1661) * fix(app): keep compact explorer mounted while open * fix(app): share compact explorer host state * fix(app): refresh diff row metrics after font changes --- packages/app/e2e/diff-row-alignment.spec.ts | 31 ++- packages/app/src/app/_layout.tsx | 26 +- ...ompact-explorer-sidebar-host-state.test.ts | 106 ++++++++ .../compact-explorer-sidebar-host-state.ts | 59 +++++ .../compact-explorer-sidebar-host.tsx | 162 ++++++++++++ .../app/src/components/explorer-sidebar.tsx | 243 +++++++++--------- packages/app/src/git/diff-pane.tsx | 147 +++++++++-- packages/app/src/review/surface.tsx | 28 +- .../use-workspace-checkout-status.ts | 57 ++++ .../workspace/workspace-file-open-command.ts | 31 +++ .../screens/workspace/workspace-screen.tsx | 122 +-------- 11 files changed, 745 insertions(+), 267 deletions(-) create mode 100644 packages/app/src/components/compact-explorer-sidebar-host-state.test.ts create mode 100644 packages/app/src/components/compact-explorer-sidebar-host-state.ts create mode 100644 packages/app/src/components/compact-explorer-sidebar-host.tsx create mode 100644 packages/app/src/screens/workspace/use-workspace-checkout-status.ts create mode 100644 packages/app/src/screens/workspace/workspace-file-open-command.ts diff --git a/packages/app/e2e/diff-row-alignment.spec.ts b/packages/app/e2e/diff-row-alignment.spec.ts index 62d37053d..4426f9273 100644 --- a/packages/app/e2e/diff-row-alignment.spec.ts +++ b/packages/app/e2e/diff-row-alignment.spec.ts @@ -228,6 +228,7 @@ test("changes diff keeps unwrapped gutter and code rows aligned after code size await changeCodeFontSizeFromSettings(page, 18); await returnToWorkspaceChanges(page); + await expectStoredCodeFontSize(page, 18); await scrollToLowerUnwrappedDiffRows(page); await expectDiffCodeFontSize(page, 18); @@ -238,6 +239,9 @@ test("changes diff keeps unwrapped gutter and code rows aligned after code size async function useCodeFont(page: Page, codeFontSize: number): Promise { await page.addInitScript( ({ settingsKey, fontSize }) => { + if (localStorage.getItem(settingsKey)) { + return; + } localStorage.setItem( settingsKey, JSON.stringify({ @@ -270,10 +274,13 @@ async function useUnwrappedDiffLines(page: Page): Promise { } async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise { - const actualFontSize = await page - .getByTestId("diff-code-text-1") - .evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize)); - expect(actualFontSize).toBe(fontSize); + await expect + .poll(async () => { + return page + .getByTestId("diff-code-text-1") + .evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize)); + }) + .toBe(fontSize); } async function expectVisibleDiffRowsAligned(page: Page): Promise { @@ -400,6 +407,22 @@ async function changeCodeFontSizeFromSettings(page: Page, codeFontSize: number): await page.getByLabel("Code font size").fill(String(codeFontSize)); await page.getByLabel("Code font size").press("Enter"); await expect(page.getByLabel("Code font size")).toHaveValue(String(codeFontSize)); + await expectStoredCodeFontSize(page, codeFontSize); +} + +async function expectStoredCodeFontSize(page: Page, codeFontSize: number): Promise { + await expect + .poll(async () => { + const raw = await page.evaluate( + (settingsKey) => localStorage.getItem(settingsKey), + APP_SETTINGS_KEY, + ); + if (!raw) { + return null; + } + return (JSON.parse(raw) as { codeFontSize?: number }).codeFontSize ?? null; + }) + .toBe(codeFontSize); } async function returnToWorkspaceChanges(page: Page): Promise { diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index b04fa21a5..5b7e3165a 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -28,6 +28,7 @@ import { DownloadToast } from "@/components/download-toast"; import { QuittingOverlay } from "@/components/quitting-overlay"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { LeftSidebar } from "@/components/left-sidebar"; +import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host"; import { ProjectPickerModal } from "@/components/project-picker-modal"; import { ProviderSettingsHost } from "@/components/provider-settings-host"; import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; @@ -40,6 +41,7 @@ import { useHorizontalScrollOptional, } from "@/contexts/horizontal-scroll-context"; import { SessionProvider } from "@/contexts/session-context"; +import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; import { SidebarAnimationProvider, useSidebarAnimation, @@ -465,14 +467,26 @@ function AppContainer({ useActiveWorktreeNewAction(); useGlobalNewWorkspaceAction(); + const workspaceChrome = ( + + {!isCompactLayout && chromeEnabled && !isFocusModeEnabled && ( + + )} + {isCompactLayout && chromeEnabled ? ( + + + {children} + + + ) : ( + {children} + )} + + ); + const content = ( - - {!isCompactLayout && chromeEnabled && !isFocusModeEnabled && ( - - )} - {children} - + {workspaceChrome} {isCompactLayout && chromeEnabled && } diff --git a/packages/app/src/components/compact-explorer-sidebar-host-state.test.ts b/packages/app/src/components/compact-explorer-sidebar-host-state.test.ts new file mode 100644 index 000000000..0e55872c0 --- /dev/null +++ b/packages/app/src/components/compact-explorer-sidebar-host-state.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { + resolveCompactExplorerSidebarHostModel, + type CompactExplorerSidebarHostModel, +} from "@/components/compact-explorer-sidebar-host-state"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; + +function createWorkspace( + input: Partial & Pick, +): WorkspaceDescriptor { + return { + id: input.id, + projectId: input.projectId ?? "project-1", + projectDisplayName: input.projectDisplayName ?? "Project 1", + projectRootPath: input.projectRootPath ?? "/repo", + workspaceDirectory: input.workspaceDirectory ?? "/repo", + projectKind: input.projectKind ?? "git", + workspaceKind: input.workspaceKind ?? "local_checkout", + name: input.name ?? "main", + status: input.status ?? "done", + archivingAt: input.archivingAt ?? null, + statusEnteredAt: null, + diffStat: input.diffStat ?? null, + scripts: input.scripts ?? [], + }; +} + +function createModel( + overrides: Partial = {}, +): CompactExplorerSidebarHostModel { + return { + serverId: overrides.serverId ?? "server-1", + workspaceId: overrides.workspaceId ?? "workspace-a", + persistenceKey: overrides.persistenceKey ?? "server-1:workspace-a", + workspaceRoot: overrides.workspaceRoot ?? "/repo/a", + isGit: overrides.isGit ?? true, + }; +} + +describe("resolveCompactExplorerSidebarHostModel", () => { + it("retains the last workspace root for the same active selection while the workspace reloads", () => { + const previous = createModel(); + + const result = resolveCompactExplorerSidebarHostModel({ + previous, + selection: { serverId: "server-1", workspaceId: "workspace-a" }, + workspace: null, + isGit: false, + }); + + expect(result).toEqual({ + serverId: "server-1", + workspaceId: "workspace-a", + persistenceKey: "server-1:workspace-a", + workspaceRoot: "/repo/a", + isGit: true, + }); + }); + + it("switches ownership to the active workspace instead of leaking the previous one", () => { + const previous = createModel(); + + const result = resolveCompactExplorerSidebarHostModel({ + previous, + selection: { serverId: "server-1", workspaceId: "workspace-b" }, + workspace: null, + isGit: false, + }); + + expect(result).toEqual({ + serverId: "server-1", + workspaceId: "workspace-b", + persistenceKey: "server-1:workspace-b", + workspaceRoot: "", + isGit: false, + }); + }); + + it("does not retain a previous owner when there is no active workspace selection", () => { + const result = resolveCompactExplorerSidebarHostModel({ + previous: createModel(), + selection: null, + workspace: null, + isGit: false, + }); + + expect(result).toBeNull(); + }); + + it("uses the current workspace directory when it is available", () => { + const result = resolveCompactExplorerSidebarHostModel({ + previous: null, + selection: { serverId: "server-1", workspaceId: "workspace-a" }, + workspace: createWorkspace({ id: "workspace-a", workspaceDirectory: "/repo/current" }), + isGit: true, + }); + + expect(result).toEqual({ + serverId: "server-1", + workspaceId: "workspace-a", + persistenceKey: "server-1:workspace-a", + workspaceRoot: "/repo/current", + isGit: true, + }); + }); +}); diff --git a/packages/app/src/components/compact-explorer-sidebar-host-state.ts b/packages/app/src/components/compact-explorer-sidebar-host-state.ts new file mode 100644 index 000000000..c0d201623 --- /dev/null +++ b/packages/app/src/components/compact-explorer-sidebar-host-state.ts @@ -0,0 +1,59 @@ +import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-layout-store"; +import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; + +export interface CompactExplorerSidebarHostModel { + serverId: string; + workspaceId: string; + persistenceKey: string; + workspaceRoot: string; + isGit: boolean; +} + +interface ResolveCompactExplorerSidebarHostModelInput { + previous: CompactExplorerSidebarHostModel | null; + selection: ActiveWorkspaceSelection | null; + workspace: WorkspaceDescriptor | null; + isGit: boolean; +} + +function trimNonEmpty(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function resolveCompactExplorerSidebarHostModel( + input: ResolveCompactExplorerSidebarHostModelInput, +): CompactExplorerSidebarHostModel | null { + const serverId = trimNonEmpty(input.selection?.serverId); + const workspaceId = trimNonEmpty(input.selection?.workspaceId); + if (!serverId || !workspaceId) { + return null; + } + + const persistenceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); + if (!persistenceKey) { + return null; + } + + const previousForSelection = + input.previous && + input.previous.serverId === serverId && + input.previous.workspaceId === workspaceId + ? input.previous + : null; + + return { + serverId, + workspaceId, + persistenceKey, + workspaceRoot: + trimNonEmpty(input.workspace?.workspaceDirectory) ?? + previousForSelection?.workspaceRoot ?? + "", + isGit: input.workspace ? input.isGit : (previousForSelection?.isGit ?? input.isGit), + }; +} diff --git a/packages/app/src/components/compact-explorer-sidebar-host.tsx b/packages/app/src/components/compact-explorer-sidebar-host.tsx new file mode 100644 index 000000000..5e79d4a0a --- /dev/null +++ b/packages/app/src/components/compact-explorer-sidebar-host.tsx @@ -0,0 +1,162 @@ +import { type ReactNode, useCallback, useEffect, useMemo, useRef } from "react"; +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 { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { selectIsFileExplorerOpen, 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"; +import { isWeb } from "@/constants/platform"; +import { + resolveCompactExplorerSidebarHostModel, + type CompactExplorerSidebarHostModel, +} from "@/components/compact-explorer-sidebar-host-state"; + +interface CompactExplorerOpenGestureSurfaceProps { + children: ReactNode; + enabled: boolean; + onOpenExplorer: () => void; +} + +const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y"; + +function CompactExplorerOpenGestureSurface({ + children, + enabled, + onOpenExplorer, +}: CompactExplorerOpenGestureSurfaceProps) { + const explorerOpenGesture = useExplorerOpenGesture({ + enabled, + onOpen: onOpenExplorer, + }); + + return ( + + {children} + + ); +} + +function useActiveCompactExplorerSidebarModel( + enabled: boolean, +): CompactExplorerSidebarHostModel | null { + const selection = useActiveWorkspaceSelection(); + const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null); + const isExplorerOpen = usePanelStore((state) => + selectIsFileExplorerOpen(state, { isCompact: true }), + ); + const showMobileAgent = usePanelStore((state) => state.showMobileAgent); + const client = useHostRuntimeClient(selection?.serverId ?? ""); + const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? ""); + const retainedModelRef = useRef(null); + const { checkoutQuery } = useWorkspaceCheckoutStatus({ + client, + isConnected, + isRouteFocused: enabled && selection !== null, + normalizedServerId: selection?.serverId ?? "", + normalizedWorkspaceId: selection?.workspaceId ?? "", + workspaceDirectory: workspace?.workspaceDirectory || null, + }); + const resolvedModel = useMemo( + () => + resolveCompactExplorerSidebarHostModel({ + previous: isExplorerOpen ? retainedModelRef.current : null, + selection, + workspace, + isGit: checkoutQuery.data?.isGit ?? false, + }), + [checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace], + ); + + useEffect(() => { + if (!selection) { + retainedModelRef.current = null; + if (enabled && isExplorerOpen) { + showMobileAgent(); + } + return; + } + if (!isExplorerOpen) { + retainedModelRef.current = null; + return; + } + if (resolvedModel) { + retainedModelRef.current = resolvedModel; + } + }, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]); + + return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null; +} + +interface CompactExplorerSidebarHostProps { + children: ReactNode; + enabled: boolean; +} + +export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) { + const model = useActiveCompactExplorerSidebarModel(enabled); + const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); + const showMobileAgent = usePanelStore((state) => state.showMobileAgent); + const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused); + const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab); + + const handleOpenExplorer = useCallback(() => { + if (!model?.workspaceRoot) { + return; + } + openFileExplorerForCheckout({ + isCompact: true, + checkout: { + serverId: model.serverId, + cwd: model.workspaceRoot, + isGit: model.isGit, + }, + }); + }, [model, openFileExplorerForCheckout]); + + const handleOpenFile = useCallback( + (filePath: string) => { + if (!model) { + return; + } + openWorkspaceFileFromExplorer({ + filePath, + persistenceKey: model.persistenceKey, + showMobileAgent, + openWorkspaceTabFocused, + focusWorkspaceTab, + }); + }, + [focusWorkspaceTab, model, openWorkspaceTabFocused, showMobileAgent], + ); + + return ( + <> + + {children} + + {enabled && model ? ( + + ) : null} + + ); +} + +const styles = { + fill: { + flex: 1, + }, +} as const; diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index a8f4c2ada..aa9217ef1 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -7,7 +7,6 @@ import { StyleSheet as RNStyleSheet, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useIsFocused } from "@react-navigation/native"; import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -29,7 +28,7 @@ import { import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state"; -import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; +import { HEADER_INNER_HEIGHT } from "@/constants/layout"; import { GitDiffPane } from "@/git/diff-pane"; import { FileExplorerPane } from "./file-explorer-pane"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; @@ -49,7 +48,29 @@ interface ExplorerSidebarProps { onOpenFile?: (filePath: string) => void; } -export function ExplorerSidebar({ +interface ExplorerSidebarSharedState { + explorerTab: ExplorerTab; + handleTabPress: (tab: ExplorerTab) => void; +} + +function useExplorerSidebarSharedState({ + serverId, + workspaceRoot, + isGit, +}: Pick): ExplorerSidebarSharedState { + const explorerTab = usePanelStore((state) => state.explorerTab); + const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); + const handleTabPress = useCallback( + (tab: ExplorerTab) => { + setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab }); + }, + [isGit, serverId, setExplorerTabForCheckout, workspaceRoot], + ); + + return { explorerTab, handleTabPress }; +} + +export function CompactExplorerSidebar({ serverId, workspaceId, workspaceRoot, @@ -57,40 +78,22 @@ export function ExplorerSidebar({ onOpenFile, }: ExplorerSidebarProps) { const { theme } = useUnistyles(); - const isScreenFocused = useIsFocused(); const insets = useSafeAreaInsets(); - const isMobile = useIsCompactFormFactor(); - const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile })); + const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: true })); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); - const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer); - const explorerTab = usePanelStore((state) => state.explorerTab); - const explorerWidth = usePanelStore((state) => state.explorerWidth); - const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); - const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth); - const { width: viewportWidth } = useWindowDimensions(); + const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({ + serverId, + workspaceRoot, + isGit, + }); const closeTouchStartX = useSharedValue(0); const closeTouchStartY = useSharedValue(0); const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } = useSidebarAnimation(); - const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({ mode: "padding", - enabled: isMobile, + enabled: true, }); - - useEffect(() => { - if (isMobile) { - return; - } - const maxWidth = Math.max( - MIN_EXPLORER_SIDEBAR_WIDTH, - Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), - ); - if (explorerWidth > maxWidth) { - setExplorerWidth(maxWidth); - } - }, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]); - const { translateX, backdropOpacity, @@ -103,23 +106,15 @@ export function ExplorerSidebar({ closeGestureRef, } = useExplorerSidebarAnimation(); - // For resize drag, track the starting width - const startWidthRef = useRef(explorerWidth); - const resizeWidth = useSharedValue(explorerWidth); - const handleClose = useCallback( (reason: string) => { logExplorerSidebar("handleClose", { reason, isOpen, }); - if (isMobile) { - showMobileAgent(); - return; - } - closeDesktopFileExplorer(); + showMobileAgent(); }, - [closeDesktopFileExplorer, isMobile, isOpen, showMobileAgent], + [isOpen, showMobileAgent], ); const handleCloseFromGesture = useCallback(() => { @@ -128,24 +123,14 @@ export function ExplorerSidebar({ showMobileAgent(); }, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, showMobileAgent]); - const enableSidebarCloseGesture = isMobile; - - const handleTabPress = useCallback( - (tab: ExplorerTab) => { - setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab }); - }, - [isGit, serverId, setExplorerTabForCheckout, workspaceRoot], - ); - const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]); - const handleDesktopClose = useCallback(() => handleClose("desktop-close-button"), [handleClose]); // Swipe gesture to close (swipe right on mobile) const closeGesture = useMemo( () => Gesture.Pan() .withRef(closeGestureRef) - .enabled(enableSidebarCloseGesture) + .enabled(true) // Use manual activation so child views keep touch streams // unless we detect an intentional right-swipe close. .manualActivation(true) @@ -219,7 +204,6 @@ export function ExplorerSidebar({ isGesturing.value = false; }), [ - enableSidebarCloseGesture, windowWidth, translateX, backdropOpacity, @@ -234,32 +218,6 @@ export function ExplorerSidebar({ ], ); - // Desktop resize gesture (drag left edge) - const resizeGesture = useMemo( - () => - Gesture.Pan() - .enabled(!isMobile) - .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) - .onStart(() => { - startWidthRef.current = explorerWidth; - resizeWidth.value = explorerWidth; - }) - .onUpdate((event) => { - // Dragging left (negative translationX) increases width - const newWidth = startWidthRef.current - event.translationX; - const maxWidth = Math.max( - MIN_EXPLORER_SIDEBAR_WIDTH, - Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), - ); - const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth)); - resizeWidth.value = clampedWidth; - }) - .onEnd(() => { - runOnJS(setExplorerWidth)(resizeWidth.value); - }), - [isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth], - ); - const sidebarAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value }], })); @@ -268,10 +226,6 @@ export function ExplorerSidebar({ opacity: backdropOpacity.value, })); - const resizeAnimatedStyle = useAnimatedStyle(() => ({ - width: resizeWidth.value, - })); - const backdropCombinedStyle = useMemo( () => [ explorerStaticStyles.backdrop, @@ -312,10 +266,6 @@ export function ExplorerSidebar({ ], [overlayVisible], ); - const desktopSidebarStyle = useMemo( - () => [explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }], - [resizeAnimatedStyle, insets.top], - ); // Mobile: full-screen overlay with gesture. // On web, keep it interactive only while open so closed sidebars don't eat taps. @@ -324,39 +274,101 @@ export function ExplorerSidebar({ else if (isOpen) overlayPointerEvents = "auto"; else overlayPointerEvents = "none"; - // Navigation stacks can keep previous screens mounted; hide sidebars for unfocused - // screens so only the active screen exposes explorer/terminal surfaces. - if (!isScreenFocused) { - return null; - } + return ( + + - if (isMobile) { - return ( - - {/* Backdrop */} - + + + + + + + ); +} - - - - - - +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 startWidthRef = useRef(explorerWidth); + const resizeWidth = useSharedValue(explorerWidth); + + useEffect(() => { + const maxWidth = Math.max( + MIN_EXPLORER_SIDEBAR_WIDTH, + Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), ); - } + if (explorerWidth > maxWidth) { + setExplorerWidth(maxWidth); + } + }, [explorerWidth, setExplorerWidth, viewportWidth]); + + 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 }) + .onStart(() => { + startWidthRef.current = explorerWidth; + resizeWidth.value = explorerWidth; + }) + .onUpdate((event) => { + const newWidth = startWidthRef.current - event.translationX; + const maxWidth = Math.max( + MIN_EXPLORER_SIDEBAR_WIDTH, + Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), + ); + const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth)); + resizeWidth.value = clampedWidth; + }) + .onEnd(() => { + runOnJS(setExplorerWidth)(resizeWidth.value); + }), + [explorerWidth, resizeWidth, setExplorerWidth, viewportWidth], + ); + + const resizeAnimatedStyle = useAnimatedStyle(() => ({ + width: resizeWidth.value, + })); + const desktopSidebarStyle = useMemo( + () => [explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }], + [resizeAnimatedStyle, insets.top], + ); - // Desktop: fixed width sidebar with resize handle if (!isOpen) { return null; } @@ -364,12 +376,11 @@ export function ExplorerSidebar({ return ( - {/* Resize handle - absolutely positioned over left border */} - void; } -function SidebarContent({ +function ExplorerSidebarContent({ activeTab, onTabPress, onClose, diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 306bb473e..de0fc1224 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -122,6 +122,7 @@ function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) { interface HighlightedTextProps { tokens: HighlightToken[]; + textMetricsStyle: TextStyle; wrapLines?: boolean; testID?: string; } @@ -140,14 +141,37 @@ function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefine : { whiteSpace: "pre", overflowWrap: "normal" }; } +function getNumericLineHeight(textMetricsStyle: TextStyle): number | undefined { + const { lineHeight } = textMetricsStyle; + return typeof lineHeight === "number" && Number.isFinite(lineHeight) ? lineHeight : undefined; +} + +function useDiffRowMetricsStyle(textMetricsStyle: TextStyle): StyleProp { + const lineHeight = getNumericLineHeight(textMetricsStyle); + return useMemo( + () => (lineHeight !== undefined ? inlineUnistylesStyle({ minHeight: lineHeight }) : null), + [lineHeight], + ); +} + function HighlightedToken({ token }: { token: HighlightToken }) { return {token.text}; } -function HighlightedText({ tokens, wrapLines = false, testID }: HighlightedTextProps) { +function HighlightedText({ + tokens, + textMetricsStyle, + wrapLines = false, + testID, +}: HighlightedTextProps) { const containerStyle = useMemo( - () => [styles.diffTextMetrics, styles.diffLineText, getWrappedTextStyle(wrapLines)], - [wrapLines], + () => [ + styles.diffTextMetrics, + textMetricsStyle, + styles.diffLineText, + getWrappedTextStyle(wrapLines), + ], + [textMetricsStyle, wrapLines], ); const keyedTokens = useMemo( @@ -246,6 +270,7 @@ function DiffGutterCell({ lineNumber, type, gutterWidth, + textMetricsStyle, reviewTarget, reviewActions, isLineHovered, @@ -256,6 +281,7 @@ function DiffGutterCell({ lineNumber: number | null; type: DiffLine["type"] | undefined | null; gutterWidth: number; + textMetricsStyle: TextStyle; reviewTarget?: ReviewableDiffTarget | null; reviewActions?: InlineReviewActions; isLineHovered?: boolean; @@ -263,23 +289,27 @@ function DiffGutterCell({ textTestID?: string; actionTestID?: string; }) { + const lineHeight = getNumericLineHeight(textMetricsStyle); + const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle); const containerStyle = useMemo( () => [ styles.gutterCell, lineTypeBackground(type), + rowMetricsStyle, inlineUnistylesStyle({ width: gutterWidth }), style, ], - [type, gutterWidth, style], + [type, rowMetricsStyle, gutterWidth, style], ); const textStyle = useMemo( () => [ styles.diffTextMetrics, + textMetricsStyle, styles.lineNumberText, type === "add" && styles.addLineNumberText, type === "remove" && styles.removeLineNumberText, ], - [type], + [textMetricsStyle, type], ); const comments = useMemo( () => @@ -297,6 +327,7 @@ function DiffGutterCell({ comments={comments} isEditorOpen={isEditorOpen} isLineHovered={isLineHovered} + lineHeight={lineHeight} onStartComment={onStartComment} style={containerStyle} actionTestID={actionTestID} @@ -311,6 +342,7 @@ function DiffGutterCell({ function DiffTextLine({ line, wrapLines, + textMetricsStyle, reviewTarget, reviewActions, onHoverChange, @@ -320,6 +352,7 @@ function DiffTextLine({ }: { line: DiffLine; wrapLines: boolean; + textMetricsStyle: TextStyle; reviewTarget?: ReviewableDiffTarget | null; reviewActions?: InlineReviewActions; onHoverChange?: (hovered: boolean) => void; @@ -328,14 +361,16 @@ function DiffTextLine({ textTestID?: string; }) { const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null; + const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle); const containerStyle = useMemo( - () => [styles.textLineContainer, lineTypeBackground(line.type)], - [line.type], + () => [styles.textLineContainer, lineTypeBackground(line.type), rowMetricsStyle], + [line.type, rowMetricsStyle], ); const textStyle = useMemo( () => [ styles.diffTextMetrics, + textMetricsStyle, styles.diffLineText, getWrappedTextStyle(wrapLines), line.type === "add" && styles.addLineText, @@ -343,7 +378,7 @@ function DiffTextLine({ line.type === "header" && styles.headerLineText, line.type === "context" && styles.contextLineText, ], - [line.type, wrapLines], + [line.type, textMetricsStyle, wrapLines], ); return ( @@ -356,7 +391,12 @@ function DiffTextLine({ style={containerStyle} > {line.type !== "header" && visibleTokens ? ( - + ) : ( {formatDiffContentText(line.content)} @@ -369,6 +409,7 @@ function DiffTextLine({ function SplitTextLine({ line, wrapLines, + textMetricsStyle, reviewActions, onHoverChange, hoverTargetKey, @@ -376,20 +417,23 @@ function SplitTextLine({ }: { line: SplitDiffDisplayLine | null; wrapLines: boolean; + textMetricsStyle: TextStyle; reviewActions?: InlineReviewActions; onHoverChange?: (hovered: boolean) => void; hoverTargetKey?: string | null; onHoverTargetChange?: (key: string | null) => void; }) { const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null; + const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle); const containerStyle = useMemo( - () => [styles.textLineContainer, lineTypeBackground(line?.type)], - [line?.type], + () => [styles.textLineContainer, lineTypeBackground(line?.type), rowMetricsStyle], + [line?.type, rowMetricsStyle], ); const textStyle = useMemo( () => [ styles.diffTextMetrics, + textMetricsStyle, styles.diffLineText, getWrappedTextStyle(wrapLines), line?.type === "add" && styles.addLineText, @@ -397,7 +441,7 @@ function SplitTextLine({ line?.type === "context" && styles.contextLineText, !line && styles.emptySplitCellText, ], - [line, wrapLines], + [line, textMetricsStyle, wrapLines], ); return ( @@ -410,7 +454,11 @@ function SplitTextLine({ style={containerStyle} > {visibleTokens ? ( - + ) : ( {formatDiffContentText(line?.content)} )} @@ -423,6 +471,7 @@ function DiffLineView({ lineNumber, gutterWidth, wrapLines, + textMetricsStyle, reviewTarget, reviewActions, }: { @@ -430,19 +479,22 @@ function DiffLineView({ lineNumber: number | null; gutterWidth: number; wrapLines: boolean; + textMetricsStyle: TextStyle; reviewTarget?: ReviewableDiffTarget | null; reviewActions?: InlineReviewActions; }) { const [isLineHovered, setIsLineHovered] = useState(false); const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null; + const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle); const containerStyle = useMemo( - () => [styles.diffLineContainer, lineTypeBackground(line.type)], - [line.type], + () => [styles.diffLineContainer, lineTypeBackground(line.type), rowMetricsStyle], + [line.type, rowMetricsStyle], ); const textStyle = useMemo( () => [ styles.diffTextMetrics, + textMetricsStyle, styles.diffLineText, getWrappedTextStyle(wrapLines), line.type === "add" && styles.addLineText, @@ -450,7 +502,7 @@ function DiffLineView({ line.type === "header" && styles.headerLineText, line.type === "context" && styles.contextLineText, ], - [line.type, wrapLines], + [line.type, textMetricsStyle, wrapLines], ); return ( @@ -464,13 +516,18 @@ function DiffLineView({ lineNumber={lineNumber} type={line.type} gutterWidth={gutterWidth} + textMetricsStyle={textMetricsStyle} reviewTarget={reviewTarget} reviewActions={reviewActions} isLineHovered={isLineHovered} style={styles.lineNumberGutter} /> {line.type !== "header" && visibleTokens ? ( - + ) : ( {formatDiffContentText(line.content)} )} @@ -482,23 +539,27 @@ function SplitDiffLine({ line, gutterWidth, wrapLines, + textMetricsStyle, reviewActions, }: { line: SplitDiffDisplayLine | null; gutterWidth: number; wrapLines: boolean; + textMetricsStyle: TextStyle; reviewActions?: InlineReviewActions; }) { const [isLineHovered, setIsLineHovered] = useState(false); const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null; + const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle); const containerStyle = useMemo( - () => [styles.diffLineContainer, lineTypeBackground(line?.type)], - [line?.type], + () => [styles.diffLineContainer, lineTypeBackground(line?.type), rowMetricsStyle], + [line?.type, rowMetricsStyle], ); const textStyle = useMemo( () => [ styles.diffTextMetrics, + textMetricsStyle, styles.diffLineText, getWrappedTextStyle(wrapLines), line?.type === "add" && styles.addLineText, @@ -506,7 +567,7 @@ function SplitDiffLine({ line?.type === "context" && styles.contextLineText, !line && styles.emptySplitCellText, ], - [line, wrapLines], + [line, textMetricsStyle, wrapLines], ); return ( @@ -520,13 +581,18 @@ function SplitDiffLine({ lineNumber={line?.lineNumber ?? null} type={line?.type} gutterWidth={gutterWidth} + textMetricsStyle={textMetricsStyle} reviewTarget={line?.reviewTarget} reviewActions={reviewActions} isLineHovered={isLineHovered} style={styles.lineNumberGutter} /> {visibleTokens ? ( - + ) : ( {formatDiffContentText(line?.content)} )} @@ -649,6 +715,7 @@ function SplitDiffColumn({ side, gutterWidth, wrapLines, + textMetricsStyle, reviewActions, showDivider = false, }: { @@ -656,6 +723,7 @@ function SplitDiffColumn({ side: "left" | "right"; gutterWidth: number; wrapLines: boolean; + textMetricsStyle: TextStyle; reviewActions?: InlineReviewActions; showDivider?: boolean; }) { @@ -677,6 +745,10 @@ function SplitDiffColumn({ ], [scrollWidth], ); + const headerLineTextStyle = useMemo( + () => [styles.diffTextMetrics, textMetricsStyle, styles.diffLineText, styles.headerLineText], + [textMetricsStyle], + ); const keyedRows = useMemo(() => rows.map((row, i) => ({ key: `row-${i}`, row })), [rows]); @@ -688,7 +760,7 @@ function SplitDiffColumn({ if (row.kind === "header") { return ( - {row.content} + {row.content} ); } @@ -704,6 +776,7 @@ function SplitDiffColumn({ line={line} gutterWidth={gutterWidth} wrapLines={wrapLines} + textMetricsStyle={textMetricsStyle} reviewActions={reviewActions} /> { if (row.kind === "header") { return ( - + ); } const line = side === "left" ? row.left : row.right; @@ -742,6 +821,7 @@ function SplitDiffColumn({ lineNumber={line?.lineNumber ?? null} type={line?.type} gutterWidth={gutterWidth} + textMetricsStyle={textMetricsStyle} reviewTarget={line?.reviewTarget} reviewActions={reviewActions} isLineHovered={ @@ -769,7 +849,7 @@ function SplitDiffColumn({ if (row.kind === "header") { return ( - {row.content} + {row.content} ); } @@ -785,6 +865,7 @@ function SplitDiffColumn({ void; testID?: string; @@ -978,6 +1061,7 @@ function DiffFileBody({ side="left" gutterWidth={gutterWidth} wrapLines={wrapLines} + textMetricsStyle={textMetricsStyle} reviewActions={reviewActions} /> @@ -1005,6 +1090,7 @@ function DiffFileBody({ lineNumber={lineNumber} gutterWidth={gutterWidth} wrapLines={wrapLines} + textMetricsStyle={textMetricsStyle} reviewTarget={reviewTarget} reviewActions={reviewActions} /> @@ -1031,6 +1117,7 @@ function DiffFileBody({ lineNumber={lineNumber} type={line.type} gutterWidth={gutterWidth} + textMetricsStyle={textMetricsStyle} reviewTarget={reviewTarget} reviewActions={reviewActions} isLineHovered={ @@ -1059,6 +1146,7 @@ function DiffFileBody({ (() => { + const monoFontFamily = appSettings.monoFontFamily.trim(); + return { + fontSize: codeFontSize, + lineHeight: diffBodyLineHeight, + ...(monoFontFamily ? { fontFamily: monoFontFamily } : null), + }; + }, [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight]); const diffModeTriggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []); const unifiedToggleStyle = useMemo( @@ -2009,6 +2105,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane layout={effectiveLayout} wrapLines={wrapLines} codeFontSize={codeFontSize} + textMetricsStyle={diffTextMetricsStyle} reviewActions={reviewActions} onBodyHeightChange={handleBodyHeightChange} testID={`diff-file-${item.fileIndex}-body`} @@ -2017,6 +2114,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane }, [ codeFontSize, + diffTextMetricsStyle, effectiveLayout, handleBodyHeightChange, handleHeaderHeightChange, @@ -2680,7 +2778,6 @@ const styles = StyleSheet.create((theme) => ({ }, })); -const HEADER_LINE_TEXT_STYLE = [styles.diffTextMetrics, styles.diffLineText, styles.headerLineText]; const FILE_SECTION_BODY_STYLE = [styles.fileSectionBodyContainer, styles.fileSectionBorder]; const DIFF_CONTENT_SPLIT_ROW_STYLE = [styles.diffContent, styles.splitRow]; const DIFF_CONTENT_ROW_STYLE = [styles.diffContent, styles.diffContentRow]; diff --git a/packages/app/src/review/surface.tsx b/packages/app/src/review/surface.tsx index fea38d388..2b7a5298f 100644 --- a/packages/app/src/review/surface.tsx +++ b/packages/app/src/review/surface.tsx @@ -282,6 +282,7 @@ export function InlineReviewGutterCell({ reviewTarget, comments, isLineHovered = false, + lineHeight, onStartComment, style, actionTestID, @@ -291,6 +292,7 @@ export function InlineReviewGutterCell({ comments: readonly ReviewDraftComment[]; isEditorOpen: boolean; isLineHovered?: boolean; + lineHeight?: number; onStartComment: (target: ReviewableDiffTarget) => void; style?: StyleProp; actionTestID?: string; @@ -334,10 +336,28 @@ export function InlineReviewGutterCell({ }, [isInteractionActive]); const pressableStyle = useCallback((): StyleProp => style, [style]); + const lineHeightStyle = useMemo>( + () => + lineHeight !== undefined + ? inlineUnistylesStyle({ height: lineHeight, minHeight: lineHeight }) + : null, + [lineHeight], + ); const labelStyle = useMemo>( - () => [styles.gutterLabel, hasComments && styles.gutterLabelActive], - [hasComments], + () => [styles.gutterLabel, lineHeightStyle, hasComments && styles.gutterLabelActive], + [hasComments, lineHeightStyle], + ); + const innerStyle = useMemo>( + () => [styles.gutterInner, lineHeightStyle], + [lineHeightStyle], + ); + const actionIconStyle = useMemo>( + () => [ + styles.gutterActionIcon, + lineHeight !== undefined && inlineUnistylesStyle({ top: Math.floor((lineHeight - 22) / 2) }), + ], + [lineHeight], ); return ( @@ -353,11 +373,11 @@ export function InlineReviewGutterCell({ onPressOut={handlePressOut} style={pressableStyle} > - + {children} {showAction ? ( - + ) : null} diff --git a/packages/app/src/screens/workspace/use-workspace-checkout-status.ts b/packages/app/src/screens/workspace/use-workspace-checkout-status.ts new file mode 100644 index 000000000..e51f5eef2 --- /dev/null +++ b/packages/app/src/screens/workspace/use-workspace-checkout-status.ts @@ -0,0 +1,57 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { checkoutStatusQueryKey } from "@/git/query-keys"; +import { fetchCheckoutStatus } from "@/git/checkout-status-cache"; +import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; + +interface UseWorkspaceCheckoutStatusInput { + client: ReturnType; + isConnected: boolean; + isRouteFocused: boolean; + normalizedServerId: string; + normalizedWorkspaceId: string; + workspaceDirectory: string | null; +} + +export function useWorkspaceCheckoutStatus(input: UseWorkspaceCheckoutStatusInput) { + const { t } = useTranslation(); + const isCheckoutQueryEnabled = useMemo( + () => + canCreateWorkspaceTerminal({ + isRouteFocused: input.isRouteFocused, + client: input.client, + isConnected: input.isConnected, + workspaceDirectory: input.workspaceDirectory, + }), + [input.client, input.isConnected, input.isRouteFocused, input.workspaceDirectory], + ); + const checkoutQuery = useQuery({ + queryKey: checkoutStatusQueryKey( + input.normalizedServerId, + input.workspaceDirectory ?? `missing-workspace-directory:${input.normalizedWorkspaceId}`, + ), + enabled: isCheckoutQueryEnabled, + queryFn: async () => { + if (!input.client || !input.workspaceDirectory) { + throw new Error(t("workspace.terminal.hostDisconnected")); + } + return await fetchCheckoutStatus({ + client: input.client, + serverId: input.normalizedServerId, + cwd: input.workspaceDirectory, + }); + }, + staleTime: Infinity, + refetchOnMount: true, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }); + const isCheckoutStatusLoading = useMemo( + () => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError, + [checkoutQuery.data, checkoutQuery.isError, isCheckoutQueryEnabled], + ); + + return { checkoutQuery, isCheckoutStatusLoading }; +} diff --git a/packages/app/src/screens/workspace/workspace-file-open-command.ts b/packages/app/src/screens/workspace/workspace-file-open-command.ts new file mode 100644 index 000000000..0ec7cc8ac --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-file-open-command.ts @@ -0,0 +1,31 @@ +import { + createWorkspaceFileTabTarget, + normalizeWorkspaceFileLocation, +} from "@/workspace/file-open"; +import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; + +interface OpenWorkspaceFileFromExplorerInput { + filePath: string; + persistenceKey: string | null; + showMobileAgent: () => void; + openWorkspaceTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; + focusWorkspaceTab: (workspaceKey: string, tabId: string) => void; +} + +export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplorerInput): void { + input.showMobileAgent(); + if (!input.persistenceKey) { + return; + } + const location = normalizeWorkspaceFileLocation({ path: input.filePath }); + if (!location) { + return; + } + const tabId = input.openWorkspaceTabFocused( + input.persistenceKey, + createWorkspaceFileTabTarget(location), + ); + if (tabId) { + input.focusWorkspaceTab(input.persistenceKey, tabId); + } +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 33668dc47..39dc14391 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -11,7 +11,7 @@ import { import { useStoreWithEqualityFn } from "zustand/traditional"; import { useIsFocused } from "@react-navigation/native"; import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { useRouter, type Href } from "expo-router"; import * as Clipboard from "expo-clipboard"; import { useTranslation } from "react-i18next"; @@ -34,7 +34,6 @@ import { SquareTerminal, X, } from "lucide-react-native"; -import { GestureDetector } from "react-native-gesture-handler"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; @@ -66,9 +65,7 @@ import { WorkspaceGitActions } 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 { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; import { useToast } from "@/contexts/toast-context"; -import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { @@ -105,8 +102,6 @@ import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/works import { useWorkspace } from "@/stores/session-store-hooks"; import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; import type { CheckoutStatusPayload } from "@/git/use-status-query"; -import { checkoutStatusQueryKey } from "@/git/query-keys"; -import { fetchCheckoutStatus } from "@/git/checkout-status-cache"; import { confirmDialog } from "@/utils/confirm-dialog"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useStableEvent } from "@/hooks/use-stable-event"; @@ -178,7 +173,6 @@ import { buildSettingsHostRoute, buildSettingsHostSectionRoute, } from "@/utils/host-routes"; -import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state"; import { useWorkspaceTerminals, type TerminalProfileInput, @@ -196,6 +190,7 @@ import { type WorkspaceFileOpenRequest, } from "@/workspace/file-open"; import { RenderProfile } from "@/utils/render-profiler"; +import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000; const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels"; @@ -203,7 +198,6 @@ const EMPTY_UI_TABS: WorkspaceTab[] = []; const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = []; const EMPTY_PINNED_AGENT_IDS = new Set(); const EMPTY_SET = new Set(); -const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y"; function getWorkspaceScripts( workspaceDescriptor: WorkspaceDescriptor | null | undefined, @@ -868,29 +862,6 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({ ); }); -interface MobileExplorerOpenGestureSurfaceProps { - children: ReactNode; - enabled: boolean; - onOpenExplorer: () => void; -} - -function MobileExplorerOpenGestureSurface({ - children, - enabled, - onOpenExplorer, -}: MobileExplorerOpenGestureSurfaceProps) { - const explorerOpenGesture = useExplorerOpenGesture({ - enabled, - onOpen: onOpenExplorer, - }); - - return ( - - {children} - - ); -} - function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) { const cacheRef = useRef(new Map()); const tabDescriptorMap = useMemo(() => { @@ -923,16 +894,12 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({ isRouteFocused, }: WorkspaceScreenProps) { const navigationFocused = useIsFocused(); - const effectiveRouteFocused = isRouteFocused ?? navigationFocused; - return ( - - - + ); }); @@ -1594,7 +1561,7 @@ function shouldShowWorkspaceExplorerSidebar(input: { isFocusModeEnabled: boolean; isMobile: boolean; }): boolean { - return input.isRouteFocused && shouldShowWorkspaceScreenHeader(input); + return !input.isMobile && input.isRouteFocused && shouldShowWorkspaceScreenHeader(input); } function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null { @@ -1676,56 +1643,6 @@ function useWorkspaceTerminalTabActions({ }; } -function useWorkspaceCheckoutStatus(input: { - client: ReturnType; - isConnected: boolean; - isRouteFocused: boolean; - normalizedServerId: string; - normalizedWorkspaceId: string; - workspaceDirectory: string | null; -}) { - const { t } = useTranslation(); - const isCheckoutQueryEnabled = useMemo( - () => - canCreateWorkspaceTerminal({ - isRouteFocused: input.isRouteFocused, - client: input.client, - isConnected: input.isConnected, - workspaceDirectory: input.workspaceDirectory, - }), - [input.isRouteFocused, input.client, input.isConnected, input.workspaceDirectory], - ); - const checkoutQuery = useQuery({ - queryKey: checkoutStatusQueryKey( - input.normalizedServerId, - input.workspaceDirectory ?? `missing-workspace-directory:${input.normalizedWorkspaceId}`, - ), - enabled: isCheckoutQueryEnabled, - queryFn: async () => { - if (!input.client || !input.workspaceDirectory) { - throw new Error(t("workspace.terminal.hostDisconnected")); - } - return await fetchCheckoutStatus({ - client: input.client, - serverId: input.normalizedServerId, - cwd: input.workspaceDirectory, - }); - }, - staleTime: Infinity, - // Refetch on mount only after explicit invalidation (e.g. reconnect) — see - // useCheckoutStatusQuery for the rationale. - refetchOnMount: true, - refetchOnReconnect: false, - refetchOnWindowFocus: false, - }); - const isCheckoutStatusLoading = useMemo( - () => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError, - [isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError], - ); - - return { checkoutQuery, isCheckoutStatusLoading }; -} - function WorkspaceScreenContent({ serverId, workspaceId, @@ -1890,7 +1807,6 @@ function WorkspaceScreenContent({ const isExplorerOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }), ); - const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); const toggleFileExplorerForCheckout = usePanelStore( (state) => state.toggleFileExplorerForCheckout, ); @@ -1907,16 +1823,6 @@ function WorkspaceScreenContent({ }; }, [isGitCheckout, normalizedServerId, workspaceDirectory]); - const openExplorerForWorkspace = useCallback(() => { - if (!activeExplorerCheckout) { - return; - } - openFileExplorerForCheckout({ - isCompact: isMobile, - checkout: activeExplorerCheckout, - }); - }, [activeExplorerCheckout, isMobile, openFileExplorerForCheckout]); - const handleToggleExplorer = useCallback(() => { if (!activeExplorerCheckout) { return; @@ -2287,9 +2193,6 @@ function WorkspaceScreenContent({ const handleOpenFileFromExplorer = useCallback( function handleOpenFileFromExplorer(filePath: string) { - if (isMobile) { - showMobileAgent(); - } if (!persistenceKey) { return; } @@ -2302,7 +2205,7 @@ function WorkspaceScreenContent({ navigateToTabId(tabId); } }, - [isMobile, navigateToTabId, openWorkspaceTabFocused, persistenceKey, showMobileAgent], + [navigateToTabId, openWorkspaceTabFocused, persistenceKey], ); const handleOpenFileFromChat = useCallback( @@ -3695,12 +3598,7 @@ function WorkspaceScreenContent({ {isMobile ? ( - - {content} - + {content} ) : ( {desktopContent} )}