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
+27 -4
View File
@@ -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<void> {
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<void> {
}
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
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<void> {
@@ -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<void> {
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<void> {
+20 -6
View File
@@ -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 = (
<View style={rowStyle}>
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
<LeftSidebar selectedAgentId={selectedAgentId} />
)}
{isCompactLayout && chromeEnabled ? (
<ExplorerSidebarAnimationProvider>
<CompactExplorerSidebarHost enabled={chromeEnabled}>
<View style={flexStyle}>{children}</View>
</CompactExplorerSidebarHost>
</ExplorerSidebarAnimationProvider>
) : (
<View style={flexStyle}>{children}</View>
)}
</View>
);
const content = (
<View style={layoutStyles.surfaceFill}>
<View style={rowStyle}>
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
<LeftSidebar selectedAgentId={selectedAgentId} />
)}
<View style={flexStyle}>{children}</View>
</View>
{workspaceChrome}
<FloatingPanelPortalHost />
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
@@ -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,
+122 -25
View File
@@ -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<ViewStyle> {
const lineHeight = getNumericLineHeight(textMetricsStyle);
return useMemo(
() => (lineHeight !== undefined ? inlineUnistylesStyle({ minHeight: lineHeight }) : null),
[lineHeight],
);
}
function HighlightedToken({ token }: { token: HighlightToken }) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</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 ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} testID={textTestID} />
<HighlightedText
tokens={visibleTokens}
textMetricsStyle={textMetricsStyle}
wrapLines={wrapLines}
testID={textTestID}
/>
) : (
<Text style={textStyle} testID={textTestID}>
{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 ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
<HighlightedText
tokens={visibleTokens}
textMetricsStyle={textMetricsStyle}
wrapLines={wrapLines}
/>
) : (
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
)}
@@ -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 ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
<HighlightedText
tokens={visibleTokens}
textMetricsStyle={textMetricsStyle}
wrapLines={wrapLines}
/>
) : (
<Text style={textStyle}>{formatDiffContentText(line.content)}</Text>
)}
@@ -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 ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
<HighlightedText
tokens={visibleTokens}
textMetricsStyle={textMetricsStyle}
wrapLines={wrapLines}
/>
) : (
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
)}
@@ -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 (
<View key={key} style={styles.splitHeaderRow}>
<Text style={HEADER_LINE_TEXT_STYLE}>{row.content}</Text>
<Text style={headerLineTextStyle}>{row.content}</Text>
</View>
);
}
@@ -704,6 +776,7 @@ function SplitDiffColumn({
line={line}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
reviewActions={reviewActions}
/>
<InlineReviewRow
@@ -726,7 +799,13 @@ function SplitDiffColumn({
{keyedRows.map(({ key, row }) => {
if (row.kind === "header") {
return (
<DiffGutterCell key={key} lineNumber={null} type="header" gutterWidth={gutterWidth} />
<DiffGutterCell
key={key}
lineNumber={null}
type="header"
gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
/>
);
}
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 (
<View key={key} style={styles.splitHeaderRow}>
<Text style={HEADER_LINE_TEXT_STYLE}>{row.content}</Text>
<Text style={headerLineTextStyle}>{row.content}</Text>
</View>
);
}
@@ -785,6 +865,7 @@ function SplitDiffColumn({
<SplitTextLine
line={line}
wrapLines={false}
textMetricsStyle={textMetricsStyle}
reviewActions={reviewActions}
hoverTargetKey={reviewTargetKey}
onHoverTargetChange={setHoveredReviewTargetKey}
@@ -910,6 +991,7 @@ function DiffFileBody({
layout,
wrapLines,
codeFontSize,
textMetricsStyle,
reviewActions,
onBodyHeightChange,
testID,
@@ -918,6 +1000,7 @@ function DiffFileBody({
layout: "unified" | "split";
wrapLines: boolean;
codeFontSize: number;
textMetricsStyle: TextStyle;
reviewActions?: InlineReviewActions;
onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void;
testID?: string;
@@ -978,6 +1061,7 @@ function DiffFileBody({
side="left"
gutterWidth={gutterWidth}
wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
reviewActions={reviewActions}
/>
<SplitDiffColumn
@@ -985,6 +1069,7 @@ function DiffFileBody({
side="right"
gutterWidth={gutterWidth}
wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
reviewActions={reviewActions}
showDivider
/>
@@ -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({
<DiffTextLine
line={line}
wrapLines={false}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
hoverTargetKey={reviewTarget?.key ?? null}
@@ -1621,6 +1709,14 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
const diffBodyTypographyKey = [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight].join(
":",
);
const diffTextMetricsStyle = useMemo<TextStyle>(() => {
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];
+24 -4
View File
@@ -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<ViewStyle>;
actionTestID?: string;
@@ -334,10 +336,28 @@ export function InlineReviewGutterCell({
}, [isInteractionActive]);
const pressableStyle = useCallback((): StyleProp<ViewStyle> => style, [style]);
const lineHeightStyle = useMemo<StyleProp<ViewStyle>>(
() =>
lineHeight !== undefined
? inlineUnistylesStyle({ height: lineHeight, minHeight: lineHeight })
: null,
[lineHeight],
);
const labelStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.gutterLabel, hasComments && styles.gutterLabelActive],
[hasComments],
() => [styles.gutterLabel, lineHeightStyle, hasComments && styles.gutterLabelActive],
[hasComments, lineHeightStyle],
);
const innerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.gutterInner, lineHeightStyle],
[lineHeightStyle],
);
const actionIconStyle = useMemo<StyleProp<ViewStyle>>(
() => [
styles.gutterActionIcon,
lineHeight !== undefined && inlineUnistylesStyle({ top: Math.floor((lineHeight - 22) / 2) }),
],
[lineHeight],
);
return (
@@ -353,11 +373,11 @@ export function InlineReviewGutterCell({
onPressOut={handlePressOut}
style={pressableStyle}
>
<View style={styles.gutterInner}>
<View style={innerStyle}>
<View style={labelStyle}>
{children}
{showAction ? (
<View style={styles.gutterActionIcon} testID={actionTestID}>
<View style={actionIconStyle} testID={actionTestID}>
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
</View>
) : null}
@@ -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<typeof useHostRuntimeClient>;
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 };
}
@@ -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);
}
}
@@ -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<string>();
const EMPTY_SET = new Set<string>();
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 (
<GestureDetector gesture={explorerOpenGesture} touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}>
<View style={styles.content}>{children}</View>
</GestureDetector>
);
}
function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
const cacheRef = useRef(new Map<string, WorkspaceTabDescriptor>());
const tabDescriptorMap = useMemo(() => {
@@ -923,16 +894,12 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
isRouteFocused,
}: WorkspaceScreenProps) {
const navigationFocused = useIsFocused();
const effectiveRouteFocused = isRouteFocused ?? navigationFocused;
return (
<ExplorerSidebarAnimationProvider>
<WorkspaceScreenContent
serverId={serverId}
workspaceId={workspaceId}
isRouteFocused={effectiveRouteFocused}
/>
</ExplorerSidebarAnimationProvider>
<WorkspaceScreenContent
serverId={serverId}
workspaceId={workspaceId}
isRouteFocused={isRouteFocused ?? navigationFocused}
/>
);
});
@@ -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<typeof useHostRuntimeClient>;
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({
<View style={styles.centerContent}>
{isMobile ? (
<MobileExplorerOpenGestureSurface
enabled={Boolean(activeExplorerCheckout)}
onOpenExplorer={openExplorerForWorkspace}
>
{content}
</MobileExplorerOpenGestureSurface>
<View style={styles.content}>{content}</View>
) : (
<View style={styles.content}>{desktopContent}</View>
)}