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
This commit is contained in:
Mohamed Boudra
2026-06-23 16:10:37 +08:00
committed by GitHub
parent 26b2f25050
commit c03e7b82b4
11 changed files with 745 additions and 267 deletions
@@ -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<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): 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> = {},
): 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,
});
});
});
@@ -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),
};
}
@@ -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 (
<GestureDetector gesture={explorerOpenGesture} touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}>
<View style={styles.fill}>{children}</View>
</GestureDetector>
);
}
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<CompactExplorerSidebarHostModel | null>(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 (
<>
<CompactExplorerOpenGestureSurface
enabled={enabled && Boolean(model?.workspaceRoot)}
onOpenExplorer={handleOpenExplorer}
>
{children}
</CompactExplorerOpenGestureSurface>
{enabled && model ? (
<CompactExplorerSidebar
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
onOpenFile={handleOpenFile}
/>
) : null}
</>
);
}
const styles = {
fill: {
flex: 1,
},
} as const;
+127 -116
View File
@@ -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<ExplorerSidebarProps, "serverId" | "workspaceRoot" | "isGit">): 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 (
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
<Animated.View style={backdropCombinedStyle} />
if (isMobile) {
return (
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
{/* Backdrop */}
<Animated.View style={backdropCombinedStyle} />
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<ExplorerSidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleHeaderClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</Animated.View>
</GestureDetector>
</View>
);
}
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleHeaderClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile={isMobile}
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</Animated.View>
</GestureDetector>
</View>
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 (
<Animated.View style={desktopSidebarStyle}>
<View style={DESKTOP_SIDEBAR_BORDER_STYLE}>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}>
<View style={RESIZE_HANDLE_STYLE} />
</GestureDetector>
<SidebarContent
<ExplorerSidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleDesktopClose}
@@ -427,7 +438,7 @@ interface SidebarContentProps {
onOpenFile?: (filePath: string) => void;
}
function SidebarContent({
function ExplorerSidebarContent({
activeTab,
onTabPress,
onClose,