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 changeCodeFontSizeFromSettings(page, 18);
await returnToWorkspaceChanges(page); await returnToWorkspaceChanges(page);
await expectStoredCodeFontSize(page, 18);
await scrollToLowerUnwrappedDiffRows(page); await scrollToLowerUnwrappedDiffRows(page);
await expectDiffCodeFontSize(page, 18); 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> { async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
await page.addInitScript( await page.addInitScript(
({ settingsKey, fontSize }) => { ({ settingsKey, fontSize }) => {
if (localStorage.getItem(settingsKey)) {
return;
}
localStorage.setItem( localStorage.setItem(
settingsKey, settingsKey,
JSON.stringify({ JSON.stringify({
@@ -270,10 +274,13 @@ async function useUnwrappedDiffLines(page: Page): Promise<void> {
} }
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> { async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
const actualFontSize = await page await expect
.getByTestId("diff-code-text-1") .poll(async () => {
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize)); return page
expect(actualFontSize).toBe(fontSize); .getByTestId("diff-code-text-1")
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
})
.toBe(fontSize);
} }
async function expectVisibleDiffRowsAligned(page: Page): Promise<void> { 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").fill(String(codeFontSize));
await page.getByLabel("Code font size").press("Enter"); await page.getByLabel("Code font size").press("Enter");
await expect(page.getByLabel("Code font size")).toHaveValue(String(codeFontSize)); 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> { 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 { QuittingOverlay } from "@/components/quitting-overlay";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { LeftSidebar } from "@/components/left-sidebar"; import { LeftSidebar } from "@/components/left-sidebar";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
import { ProjectPickerModal } from "@/components/project-picker-modal"; import { ProjectPickerModal } from "@/components/project-picker-modal";
import { ProviderSettingsHost } from "@/components/provider-settings-host"; import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
@@ -40,6 +41,7 @@ import {
useHorizontalScrollOptional, useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context"; } from "@/contexts/horizontal-scroll-context";
import { SessionProvider } from "@/contexts/session-context"; import { SessionProvider } from "@/contexts/session-context";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { import {
SidebarAnimationProvider, SidebarAnimationProvider,
useSidebarAnimation, useSidebarAnimation,
@@ -465,14 +467,26 @@ function AppContainer({
useActiveWorktreeNewAction(); useActiveWorktreeNewAction();
useGlobalNewWorkspaceAction(); 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 = ( const content = (
<View style={layoutStyles.surfaceFill}> <View style={layoutStyles.surfaceFill}>
<View style={rowStyle}> {workspaceChrome}
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
<LeftSidebar selectedAgentId={selectedAgentId} />
)}
<View style={flexStyle}>{children}</View>
</View>
<FloatingPanelPortalHost /> <FloatingPanelPortalHost />
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />} {isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast /> <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, StyleSheet as RNStyleSheet,
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated"; import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -29,7 +28,7 @@ import {
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state"; 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 { GitDiffPane } from "@/git/diff-pane";
import { FileExplorerPane } from "./file-explorer-pane"; import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
@@ -49,7 +48,29 @@ interface ExplorerSidebarProps {
onOpenFile?: (filePath: string) => void; 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, serverId,
workspaceId, workspaceId,
workspaceRoot, workspaceRoot,
@@ -57,40 +78,22 @@ export function ExplorerSidebar({
onOpenFile, onOpenFile,
}: ExplorerSidebarProps) { }: ExplorerSidebarProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isMobile = useIsCompactFormFactor(); const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: true }));
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }));
const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer); const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
const explorerTab = usePanelStore((state) => state.explorerTab); serverId,
const explorerWidth = usePanelStore((state) => state.explorerWidth); workspaceRoot,
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); isGit,
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth); });
const { width: viewportWidth } = useWindowDimensions();
const closeTouchStartX = useSharedValue(0); const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0); const closeTouchStartY = useSharedValue(0);
const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } = const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
useSidebarAnimation(); useSidebarAnimation();
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({ const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
mode: "padding", 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 { const {
translateX, translateX,
backdropOpacity, backdropOpacity,
@@ -103,23 +106,15 @@ export function ExplorerSidebar({
closeGestureRef, closeGestureRef,
} = useExplorerSidebarAnimation(); } = useExplorerSidebarAnimation();
// For resize drag, track the starting width
const startWidthRef = useRef(explorerWidth);
const resizeWidth = useSharedValue(explorerWidth);
const handleClose = useCallback( const handleClose = useCallback(
(reason: string) => { (reason: string) => {
logExplorerSidebar("handleClose", { logExplorerSidebar("handleClose", {
reason, reason,
isOpen, isOpen,
}); });
if (isMobile) { showMobileAgent();
showMobileAgent();
return;
}
closeDesktopFileExplorer();
}, },
[closeDesktopFileExplorer, isMobile, isOpen, showMobileAgent], [isOpen, showMobileAgent],
); );
const handleCloseFromGesture = useCallback(() => { const handleCloseFromGesture = useCallback(() => {
@@ -128,24 +123,14 @@ export function ExplorerSidebar({
showMobileAgent(); showMobileAgent();
}, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, 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 handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
const handleDesktopClose = useCallback(() => handleClose("desktop-close-button"), [handleClose]);
// Swipe gesture to close (swipe right on mobile) // Swipe gesture to close (swipe right on mobile)
const closeGesture = useMemo( const closeGesture = useMemo(
() => () =>
Gesture.Pan() Gesture.Pan()
.withRef(closeGestureRef) .withRef(closeGestureRef)
.enabled(enableSidebarCloseGesture) .enabled(true)
// Use manual activation so child views keep touch streams // Use manual activation so child views keep touch streams
// unless we detect an intentional right-swipe close. // unless we detect an intentional right-swipe close.
.manualActivation(true) .manualActivation(true)
@@ -219,7 +204,6 @@ export function ExplorerSidebar({
isGesturing.value = false; isGesturing.value = false;
}), }),
[ [
enableSidebarCloseGesture,
windowWidth, windowWidth,
translateX, translateX,
backdropOpacity, 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(() => ({ const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }], transform: [{ translateX: translateX.value }],
})); }));
@@ -268,10 +226,6 @@ export function ExplorerSidebar({
opacity: backdropOpacity.value, opacity: backdropOpacity.value,
})); }));
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value,
}));
const backdropCombinedStyle = useMemo( const backdropCombinedStyle = useMemo(
() => [ () => [
explorerStaticStyles.backdrop, explorerStaticStyles.backdrop,
@@ -312,10 +266,6 @@ export function ExplorerSidebar({
], ],
[overlayVisible], [overlayVisible],
); );
const desktopSidebarStyle = useMemo(
() => [explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }],
[resizeAnimatedStyle, insets.top],
);
// Mobile: full-screen overlay with gesture. // Mobile: full-screen overlay with gesture.
// On web, keep it interactive only while open so closed sidebars don't eat taps. // 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 if (isOpen) overlayPointerEvents = "auto";
else overlayPointerEvents = "none"; else overlayPointerEvents = "none";
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused return (
// screens so only the active screen exposes explorer/terminal surfaces. <View style={overlayStyle} pointerEvents={overlayPointerEvents}>
if (!isScreenFocused) { <Animated.View style={backdropCombinedStyle} />
return null;
}
if (isMobile) { <GestureDetector gesture={closeGesture} touchAction="pan-y">
return ( <Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<View style={overlayStyle} pointerEvents={overlayPointerEvents}> <ExplorerSidebarContent
{/* Backdrop */} activeTab={explorerTab}
<Animated.View style={backdropCombinedStyle} /> 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"> export function ExplorerSidebar({
<Animated.View style={mobileSidebarStyle} pointerEvents="auto"> serverId,
<SidebarContent workspaceId,
activeTab={explorerTab} workspaceRoot,
onTabPress={handleTabPress} isGit,
onClose={handleHeaderClose} onOpenFile,
serverId={serverId} }: ExplorerSidebarProps) {
workspaceId={workspaceId} const insets = useSafeAreaInsets();
workspaceRoot={workspaceRoot} const explorerWidth = usePanelStore((state) => state.explorerWidth);
isGit={isGit} const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
isMobile={isMobile} const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: false }));
isOpen={isOpen} const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
onOpenFile={onOpenFile} const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
/> serverId,
</Animated.View> workspaceRoot,
</GestureDetector> isGit,
</View> });
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) { if (!isOpen) {
return null; return null;
} }
@@ -364,12 +376,11 @@ export function ExplorerSidebar({
return ( return (
<Animated.View style={desktopSidebarStyle}> <Animated.View style={desktopSidebarStyle}>
<View style={DESKTOP_SIDEBAR_BORDER_STYLE}> <View style={DESKTOP_SIDEBAR_BORDER_STYLE}>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}> <GestureDetector gesture={resizeGesture}>
<View style={RESIZE_HANDLE_STYLE} /> <View style={RESIZE_HANDLE_STYLE} />
</GestureDetector> </GestureDetector>
<SidebarContent <ExplorerSidebarContent
activeTab={explorerTab} activeTab={explorerTab}
onTabPress={handleTabPress} onTabPress={handleTabPress}
onClose={handleDesktopClose} onClose={handleDesktopClose}
@@ -427,7 +438,7 @@ interface SidebarContentProps {
onOpenFile?: (filePath: string) => void; onOpenFile?: (filePath: string) => void;
} }
function SidebarContent({ function ExplorerSidebarContent({
activeTab, activeTab,
onTabPress, onTabPress,
onClose, onClose,
+122 -25
View File
@@ -122,6 +122,7 @@ function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
interface HighlightedTextProps { interface HighlightedTextProps {
tokens: HighlightToken[]; tokens: HighlightToken[];
textMetricsStyle: TextStyle;
wrapLines?: boolean; wrapLines?: boolean;
testID?: string; testID?: string;
} }
@@ -140,14 +141,37 @@ function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefine
: { whiteSpace: "pre", overflowWrap: "normal" }; : { 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 }) { function HighlightedToken({ token }: { token: HighlightToken }) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>; 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( const containerStyle = useMemo(
() => [styles.diffTextMetrics, styles.diffLineText, getWrappedTextStyle(wrapLines)], () => [
[wrapLines], styles.diffTextMetrics,
textMetricsStyle,
styles.diffLineText,
getWrappedTextStyle(wrapLines),
],
[textMetricsStyle, wrapLines],
); );
const keyedTokens = useMemo( const keyedTokens = useMemo(
@@ -246,6 +270,7 @@ function DiffGutterCell({
lineNumber, lineNumber,
type, type,
gutterWidth, gutterWidth,
textMetricsStyle,
reviewTarget, reviewTarget,
reviewActions, reviewActions,
isLineHovered, isLineHovered,
@@ -256,6 +281,7 @@ function DiffGutterCell({
lineNumber: number | null; lineNumber: number | null;
type: DiffLine["type"] | undefined | null; type: DiffLine["type"] | undefined | null;
gutterWidth: number; gutterWidth: number;
textMetricsStyle: TextStyle;
reviewTarget?: ReviewableDiffTarget | null; reviewTarget?: ReviewableDiffTarget | null;
reviewActions?: InlineReviewActions; reviewActions?: InlineReviewActions;
isLineHovered?: boolean; isLineHovered?: boolean;
@@ -263,23 +289,27 @@ function DiffGutterCell({
textTestID?: string; textTestID?: string;
actionTestID?: string; actionTestID?: string;
}) { }) {
const lineHeight = getNumericLineHeight(textMetricsStyle);
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
const containerStyle = useMemo( const containerStyle = useMemo(
() => [ () => [
styles.gutterCell, styles.gutterCell,
lineTypeBackground(type), lineTypeBackground(type),
rowMetricsStyle,
inlineUnistylesStyle({ width: gutterWidth }), inlineUnistylesStyle({ width: gutterWidth }),
style, style,
], ],
[type, gutterWidth, style], [type, rowMetricsStyle, gutterWidth, style],
); );
const textStyle = useMemo( const textStyle = useMemo(
() => [ () => [
styles.diffTextMetrics, styles.diffTextMetrics,
textMetricsStyle,
styles.lineNumberText, styles.lineNumberText,
type === "add" && styles.addLineNumberText, type === "add" && styles.addLineNumberText,
type === "remove" && styles.removeLineNumberText, type === "remove" && styles.removeLineNumberText,
], ],
[type], [textMetricsStyle, type],
); );
const comments = useMemo( const comments = useMemo(
() => () =>
@@ -297,6 +327,7 @@ function DiffGutterCell({
comments={comments} comments={comments}
isEditorOpen={isEditorOpen} isEditorOpen={isEditorOpen}
isLineHovered={isLineHovered} isLineHovered={isLineHovered}
lineHeight={lineHeight}
onStartComment={onStartComment} onStartComment={onStartComment}
style={containerStyle} style={containerStyle}
actionTestID={actionTestID} actionTestID={actionTestID}
@@ -311,6 +342,7 @@ function DiffGutterCell({
function DiffTextLine({ function DiffTextLine({
line, line,
wrapLines, wrapLines,
textMetricsStyle,
reviewTarget, reviewTarget,
reviewActions, reviewActions,
onHoverChange, onHoverChange,
@@ -320,6 +352,7 @@ function DiffTextLine({
}: { }: {
line: DiffLine; line: DiffLine;
wrapLines: boolean; wrapLines: boolean;
textMetricsStyle: TextStyle;
reviewTarget?: ReviewableDiffTarget | null; reviewTarget?: ReviewableDiffTarget | null;
reviewActions?: InlineReviewActions; reviewActions?: InlineReviewActions;
onHoverChange?: (hovered: boolean) => void; onHoverChange?: (hovered: boolean) => void;
@@ -328,14 +361,16 @@ function DiffTextLine({
textTestID?: string; textTestID?: string;
}) { }) {
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null; const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
const containerStyle = useMemo( const containerStyle = useMemo(
() => [styles.textLineContainer, lineTypeBackground(line.type)], () => [styles.textLineContainer, lineTypeBackground(line.type), rowMetricsStyle],
[line.type], [line.type, rowMetricsStyle],
); );
const textStyle = useMemo( const textStyle = useMemo(
() => [ () => [
styles.diffTextMetrics, styles.diffTextMetrics,
textMetricsStyle,
styles.diffLineText, styles.diffLineText,
getWrappedTextStyle(wrapLines), getWrappedTextStyle(wrapLines),
line.type === "add" && styles.addLineText, line.type === "add" && styles.addLineText,
@@ -343,7 +378,7 @@ function DiffTextLine({
line.type === "header" && styles.headerLineText, line.type === "header" && styles.headerLineText,
line.type === "context" && styles.contextLineText, line.type === "context" && styles.contextLineText,
], ],
[line.type, wrapLines], [line.type, textMetricsStyle, wrapLines],
); );
return ( return (
@@ -356,7 +391,12 @@ function DiffTextLine({
style={containerStyle} style={containerStyle}
> >
{line.type !== "header" && visibleTokens ? ( {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}> <Text style={textStyle} testID={textTestID}>
{formatDiffContentText(line.content)} {formatDiffContentText(line.content)}
@@ -369,6 +409,7 @@ function DiffTextLine({
function SplitTextLine({ function SplitTextLine({
line, line,
wrapLines, wrapLines,
textMetricsStyle,
reviewActions, reviewActions,
onHoverChange, onHoverChange,
hoverTargetKey, hoverTargetKey,
@@ -376,20 +417,23 @@ function SplitTextLine({
}: { }: {
line: SplitDiffDisplayLine | null; line: SplitDiffDisplayLine | null;
wrapLines: boolean; wrapLines: boolean;
textMetricsStyle: TextStyle;
reviewActions?: InlineReviewActions; reviewActions?: InlineReviewActions;
onHoverChange?: (hovered: boolean) => void; onHoverChange?: (hovered: boolean) => void;
hoverTargetKey?: string | null; hoverTargetKey?: string | null;
onHoverTargetChange?: (key: string | null) => void; onHoverTargetChange?: (key: string | null) => void;
}) { }) {
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null; const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
const containerStyle = useMemo( const containerStyle = useMemo(
() => [styles.textLineContainer, lineTypeBackground(line?.type)], () => [styles.textLineContainer, lineTypeBackground(line?.type), rowMetricsStyle],
[line?.type], [line?.type, rowMetricsStyle],
); );
const textStyle = useMemo( const textStyle = useMemo(
() => [ () => [
styles.diffTextMetrics, styles.diffTextMetrics,
textMetricsStyle,
styles.diffLineText, styles.diffLineText,
getWrappedTextStyle(wrapLines), getWrappedTextStyle(wrapLines),
line?.type === "add" && styles.addLineText, line?.type === "add" && styles.addLineText,
@@ -397,7 +441,7 @@ function SplitTextLine({
line?.type === "context" && styles.contextLineText, line?.type === "context" && styles.contextLineText,
!line && styles.emptySplitCellText, !line && styles.emptySplitCellText,
], ],
[line, wrapLines], [line, textMetricsStyle, wrapLines],
); );
return ( return (
@@ -410,7 +454,11 @@ function SplitTextLine({
style={containerStyle} style={containerStyle}
> >
{visibleTokens ? ( {visibleTokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} /> <HighlightedText
tokens={visibleTokens}
textMetricsStyle={textMetricsStyle}
wrapLines={wrapLines}
/>
) : ( ) : (
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text> <Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
)} )}
@@ -423,6 +471,7 @@ function DiffLineView({
lineNumber, lineNumber,
gutterWidth, gutterWidth,
wrapLines, wrapLines,
textMetricsStyle,
reviewTarget, reviewTarget,
reviewActions, reviewActions,
}: { }: {
@@ -430,19 +479,22 @@ function DiffLineView({
lineNumber: number | null; lineNumber: number | null;
gutterWidth: number; gutterWidth: number;
wrapLines: boolean; wrapLines: boolean;
textMetricsStyle: TextStyle;
reviewTarget?: ReviewableDiffTarget | null; reviewTarget?: ReviewableDiffTarget | null;
reviewActions?: InlineReviewActions; reviewActions?: InlineReviewActions;
}) { }) {
const [isLineHovered, setIsLineHovered] = useState(false); const [isLineHovered, setIsLineHovered] = useState(false);
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null; const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
const containerStyle = useMemo( const containerStyle = useMemo(
() => [styles.diffLineContainer, lineTypeBackground(line.type)], () => [styles.diffLineContainer, lineTypeBackground(line.type), rowMetricsStyle],
[line.type], [line.type, rowMetricsStyle],
); );
const textStyle = useMemo( const textStyle = useMemo(
() => [ () => [
styles.diffTextMetrics, styles.diffTextMetrics,
textMetricsStyle,
styles.diffLineText, styles.diffLineText,
getWrappedTextStyle(wrapLines), getWrappedTextStyle(wrapLines),
line.type === "add" && styles.addLineText, line.type === "add" && styles.addLineText,
@@ -450,7 +502,7 @@ function DiffLineView({
line.type === "header" && styles.headerLineText, line.type === "header" && styles.headerLineText,
line.type === "context" && styles.contextLineText, line.type === "context" && styles.contextLineText,
], ],
[line.type, wrapLines], [line.type, textMetricsStyle, wrapLines],
); );
return ( return (
@@ -464,13 +516,18 @@ function DiffLineView({
lineNumber={lineNumber} lineNumber={lineNumber}
type={line.type} type={line.type}
gutterWidth={gutterWidth} gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget} reviewTarget={reviewTarget}
reviewActions={reviewActions} reviewActions={reviewActions}
isLineHovered={isLineHovered} isLineHovered={isLineHovered}
style={styles.lineNumberGutter} style={styles.lineNumberGutter}
/> />
{line.type !== "header" && visibleTokens ? ( {line.type !== "header" && visibleTokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} /> <HighlightedText
tokens={visibleTokens}
textMetricsStyle={textMetricsStyle}
wrapLines={wrapLines}
/>
) : ( ) : (
<Text style={textStyle}>{formatDiffContentText(line.content)}</Text> <Text style={textStyle}>{formatDiffContentText(line.content)}</Text>
)} )}
@@ -482,23 +539,27 @@ function SplitDiffLine({
line, line,
gutterWidth, gutterWidth,
wrapLines, wrapLines,
textMetricsStyle,
reviewActions, reviewActions,
}: { }: {
line: SplitDiffDisplayLine | null; line: SplitDiffDisplayLine | null;
gutterWidth: number; gutterWidth: number;
wrapLines: boolean; wrapLines: boolean;
textMetricsStyle: TextStyle;
reviewActions?: InlineReviewActions; reviewActions?: InlineReviewActions;
}) { }) {
const [isLineHovered, setIsLineHovered] = useState(false); const [isLineHovered, setIsLineHovered] = useState(false);
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null; const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
const containerStyle = useMemo( const containerStyle = useMemo(
() => [styles.diffLineContainer, lineTypeBackground(line?.type)], () => [styles.diffLineContainer, lineTypeBackground(line?.type), rowMetricsStyle],
[line?.type], [line?.type, rowMetricsStyle],
); );
const textStyle = useMemo( const textStyle = useMemo(
() => [ () => [
styles.diffTextMetrics, styles.diffTextMetrics,
textMetricsStyle,
styles.diffLineText, styles.diffLineText,
getWrappedTextStyle(wrapLines), getWrappedTextStyle(wrapLines),
line?.type === "add" && styles.addLineText, line?.type === "add" && styles.addLineText,
@@ -506,7 +567,7 @@ function SplitDiffLine({
line?.type === "context" && styles.contextLineText, line?.type === "context" && styles.contextLineText,
!line && styles.emptySplitCellText, !line && styles.emptySplitCellText,
], ],
[line, wrapLines], [line, textMetricsStyle, wrapLines],
); );
return ( return (
@@ -520,13 +581,18 @@ function SplitDiffLine({
lineNumber={line?.lineNumber ?? null} lineNumber={line?.lineNumber ?? null}
type={line?.type} type={line?.type}
gutterWidth={gutterWidth} gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
reviewTarget={line?.reviewTarget} reviewTarget={line?.reviewTarget}
reviewActions={reviewActions} reviewActions={reviewActions}
isLineHovered={isLineHovered} isLineHovered={isLineHovered}
style={styles.lineNumberGutter} style={styles.lineNumberGutter}
/> />
{visibleTokens ? ( {visibleTokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} /> <HighlightedText
tokens={visibleTokens}
textMetricsStyle={textMetricsStyle}
wrapLines={wrapLines}
/>
) : ( ) : (
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text> <Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
)} )}
@@ -649,6 +715,7 @@ function SplitDiffColumn({
side, side,
gutterWidth, gutterWidth,
wrapLines, wrapLines,
textMetricsStyle,
reviewActions, reviewActions,
showDivider = false, showDivider = false,
}: { }: {
@@ -656,6 +723,7 @@ function SplitDiffColumn({
side: "left" | "right"; side: "left" | "right";
gutterWidth: number; gutterWidth: number;
wrapLines: boolean; wrapLines: boolean;
textMetricsStyle: TextStyle;
reviewActions?: InlineReviewActions; reviewActions?: InlineReviewActions;
showDivider?: boolean; showDivider?: boolean;
}) { }) {
@@ -677,6 +745,10 @@ function SplitDiffColumn({
], ],
[scrollWidth], [scrollWidth],
); );
const headerLineTextStyle = useMemo(
() => [styles.diffTextMetrics, textMetricsStyle, styles.diffLineText, styles.headerLineText],
[textMetricsStyle],
);
const keyedRows = useMemo(() => rows.map((row, i) => ({ key: `row-${i}`, row })), [rows]); const keyedRows = useMemo(() => rows.map((row, i) => ({ key: `row-${i}`, row })), [rows]);
@@ -688,7 +760,7 @@ function SplitDiffColumn({
if (row.kind === "header") { if (row.kind === "header") {
return ( return (
<View key={key} style={styles.splitHeaderRow}> <View key={key} style={styles.splitHeaderRow}>
<Text style={HEADER_LINE_TEXT_STYLE}>{row.content}</Text> <Text style={headerLineTextStyle}>{row.content}</Text>
</View> </View>
); );
} }
@@ -704,6 +776,7 @@ function SplitDiffColumn({
line={line} line={line}
gutterWidth={gutterWidth} gutterWidth={gutterWidth}
wrapLines={wrapLines} wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
reviewActions={reviewActions} reviewActions={reviewActions}
/> />
<InlineReviewRow <InlineReviewRow
@@ -726,7 +799,13 @@ function SplitDiffColumn({
{keyedRows.map(({ key, row }) => { {keyedRows.map(({ key, row }) => {
if (row.kind === "header") { if (row.kind === "header") {
return ( 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; const line = side === "left" ? row.left : row.right;
@@ -742,6 +821,7 @@ function SplitDiffColumn({
lineNumber={line?.lineNumber ?? null} lineNumber={line?.lineNumber ?? null}
type={line?.type} type={line?.type}
gutterWidth={gutterWidth} gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
reviewTarget={line?.reviewTarget} reviewTarget={line?.reviewTarget}
reviewActions={reviewActions} reviewActions={reviewActions}
isLineHovered={ isLineHovered={
@@ -769,7 +849,7 @@ function SplitDiffColumn({
if (row.kind === "header") { if (row.kind === "header") {
return ( return (
<View key={key} style={styles.splitHeaderRow}> <View key={key} style={styles.splitHeaderRow}>
<Text style={HEADER_LINE_TEXT_STYLE}>{row.content}</Text> <Text style={headerLineTextStyle}>{row.content}</Text>
</View> </View>
); );
} }
@@ -785,6 +865,7 @@ function SplitDiffColumn({
<SplitTextLine <SplitTextLine
line={line} line={line}
wrapLines={false} wrapLines={false}
textMetricsStyle={textMetricsStyle}
reviewActions={reviewActions} reviewActions={reviewActions}
hoverTargetKey={reviewTargetKey} hoverTargetKey={reviewTargetKey}
onHoverTargetChange={setHoveredReviewTargetKey} onHoverTargetChange={setHoveredReviewTargetKey}
@@ -910,6 +991,7 @@ function DiffFileBody({
layout, layout,
wrapLines, wrapLines,
codeFontSize, codeFontSize,
textMetricsStyle,
reviewActions, reviewActions,
onBodyHeightChange, onBodyHeightChange,
testID, testID,
@@ -918,6 +1000,7 @@ function DiffFileBody({
layout: "unified" | "split"; layout: "unified" | "split";
wrapLines: boolean; wrapLines: boolean;
codeFontSize: number; codeFontSize: number;
textMetricsStyle: TextStyle;
reviewActions?: InlineReviewActions; reviewActions?: InlineReviewActions;
onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void; onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void;
testID?: string; testID?: string;
@@ -978,6 +1061,7 @@ function DiffFileBody({
side="left" side="left"
gutterWidth={gutterWidth} gutterWidth={gutterWidth}
wrapLines={wrapLines} wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
reviewActions={reviewActions} reviewActions={reviewActions}
/> />
<SplitDiffColumn <SplitDiffColumn
@@ -985,6 +1069,7 @@ function DiffFileBody({
side="right" side="right"
gutterWidth={gutterWidth} gutterWidth={gutterWidth}
wrapLines={wrapLines} wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
reviewActions={reviewActions} reviewActions={reviewActions}
showDivider showDivider
/> />
@@ -1005,6 +1090,7 @@ function DiffFileBody({
lineNumber={lineNumber} lineNumber={lineNumber}
gutterWidth={gutterWidth} gutterWidth={gutterWidth}
wrapLines={wrapLines} wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget} reviewTarget={reviewTarget}
reviewActions={reviewActions} reviewActions={reviewActions}
/> />
@@ -1031,6 +1117,7 @@ function DiffFileBody({
lineNumber={lineNumber} lineNumber={lineNumber}
type={line.type} type={line.type}
gutterWidth={gutterWidth} gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget} reviewTarget={reviewTarget}
reviewActions={reviewActions} reviewActions={reviewActions}
isLineHovered={ isLineHovered={
@@ -1059,6 +1146,7 @@ function DiffFileBody({
<DiffTextLine <DiffTextLine
line={line} line={line}
wrapLines={false} wrapLines={false}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget} reviewTarget={reviewTarget}
reviewActions={reviewActions} reviewActions={reviewActions}
hoverTargetKey={reviewTarget?.key ?? null} hoverTargetKey={reviewTarget?.key ?? null}
@@ -1621,6 +1709,14 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
const diffBodyTypographyKey = [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight].join( 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 diffModeTriggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []);
const unifiedToggleStyle = useMemo( const unifiedToggleStyle = useMemo(
@@ -2009,6 +2105,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
layout={effectiveLayout} layout={effectiveLayout}
wrapLines={wrapLines} wrapLines={wrapLines}
codeFontSize={codeFontSize} codeFontSize={codeFontSize}
textMetricsStyle={diffTextMetricsStyle}
reviewActions={reviewActions} reviewActions={reviewActions}
onBodyHeightChange={handleBodyHeightChange} onBodyHeightChange={handleBodyHeightChange}
testID={`diff-file-${item.fileIndex}-body`} testID={`diff-file-${item.fileIndex}-body`}
@@ -2017,6 +2114,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
}, },
[ [
codeFontSize, codeFontSize,
diffTextMetricsStyle,
effectiveLayout, effectiveLayout,
handleBodyHeightChange, handleBodyHeightChange,
handleHeaderHeightChange, 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 FILE_SECTION_BODY_STYLE = [styles.fileSectionBodyContainer, styles.fileSectionBorder];
const DIFF_CONTENT_SPLIT_ROW_STYLE = [styles.diffContent, styles.splitRow]; const DIFF_CONTENT_SPLIT_ROW_STYLE = [styles.diffContent, styles.splitRow];
const DIFF_CONTENT_ROW_STYLE = [styles.diffContent, styles.diffContentRow]; const DIFF_CONTENT_ROW_STYLE = [styles.diffContent, styles.diffContentRow];
+24 -4
View File
@@ -282,6 +282,7 @@ export function InlineReviewGutterCell({
reviewTarget, reviewTarget,
comments, comments,
isLineHovered = false, isLineHovered = false,
lineHeight,
onStartComment, onStartComment,
style, style,
actionTestID, actionTestID,
@@ -291,6 +292,7 @@ export function InlineReviewGutterCell({
comments: readonly ReviewDraftComment[]; comments: readonly ReviewDraftComment[];
isEditorOpen: boolean; isEditorOpen: boolean;
isLineHovered?: boolean; isLineHovered?: boolean;
lineHeight?: number;
onStartComment: (target: ReviewableDiffTarget) => void; onStartComment: (target: ReviewableDiffTarget) => void;
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
actionTestID?: string; actionTestID?: string;
@@ -334,10 +336,28 @@ export function InlineReviewGutterCell({
}, [isInteractionActive]); }, [isInteractionActive]);
const pressableStyle = useCallback((): StyleProp<ViewStyle> => style, [style]); 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>>( const labelStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.gutterLabel, hasComments && styles.gutterLabelActive], () => [styles.gutterLabel, lineHeightStyle, hasComments && styles.gutterLabelActive],
[hasComments], [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 ( return (
@@ -353,11 +373,11 @@ export function InlineReviewGutterCell({
onPressOut={handlePressOut} onPressOut={handlePressOut}
style={pressableStyle} style={pressableStyle}
> >
<View style={styles.gutterInner}> <View style={innerStyle}>
<View style={labelStyle}> <View style={labelStyle}>
{children} {children}
{showAction ? ( {showAction ? (
<View style={styles.gutterActionIcon} testID={actionTestID}> <View style={actionIconStyle} testID={actionTestID}>
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} /> <ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
</View> </View>
) : null} ) : 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 { useStoreWithEqualityFn } from "zustand/traditional";
import { useIsFocused } from "@react-navigation/native"; import { useIsFocused } from "@react-navigation/native";
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-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 { useRouter, type Href } from "expo-router";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -34,7 +34,6 @@ import {
SquareTerminal, SquareTerminal,
X, X,
} from "lucide-react-native"; } from "lucide-react-native";
import { GestureDetector } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme"; 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 { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button"; import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
import { ImportSessionSheet } from "@/components/import-session-sheet"; import { ImportSessionSheet } from "@/components/import-session-sheet";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
import { import {
@@ -105,8 +102,6 @@ import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/works
import { useWorkspace } from "@/stores/session-store-hooks"; import { useWorkspace } from "@/stores/session-store-hooks";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import type { CheckoutStatusPayload } from "@/git/use-status-query"; 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 { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
@@ -178,7 +173,6 @@ import {
buildSettingsHostRoute, buildSettingsHostRoute,
buildSettingsHostSectionRoute, buildSettingsHostSectionRoute,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
import { import {
useWorkspaceTerminals, useWorkspaceTerminals,
type TerminalProfileInput, type TerminalProfileInput,
@@ -196,6 +190,7 @@ import {
type WorkspaceFileOpenRequest, type WorkspaceFileOpenRequest,
} from "@/workspace/file-open"; } from "@/workspace/file-open";
import { RenderProfile } from "@/utils/render-profiler"; 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_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels"; 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_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
const EMPTY_PINNED_AGENT_IDS = new Set<string>(); const EMPTY_PINNED_AGENT_IDS = new Set<string>();
const EMPTY_SET = new Set<string>(); const EMPTY_SET = new Set<string>();
const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
function getWorkspaceScripts( function getWorkspaceScripts(
workspaceDescriptor: WorkspaceDescriptor | null | undefined, 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[]) { function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
const cacheRef = useRef(new Map<string, WorkspaceTabDescriptor>()); const cacheRef = useRef(new Map<string, WorkspaceTabDescriptor>());
const tabDescriptorMap = useMemo(() => { const tabDescriptorMap = useMemo(() => {
@@ -923,16 +894,12 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
isRouteFocused, isRouteFocused,
}: WorkspaceScreenProps) { }: WorkspaceScreenProps) {
const navigationFocused = useIsFocused(); const navigationFocused = useIsFocused();
const effectiveRouteFocused = isRouteFocused ?? navigationFocused;
return ( return (
<ExplorerSidebarAnimationProvider> <WorkspaceScreenContent
<WorkspaceScreenContent serverId={serverId}
serverId={serverId} workspaceId={workspaceId}
workspaceId={workspaceId} isRouteFocused={isRouteFocused ?? navigationFocused}
isRouteFocused={effectiveRouteFocused} />
/>
</ExplorerSidebarAnimationProvider>
); );
}); });
@@ -1594,7 +1561,7 @@ function shouldShowWorkspaceExplorerSidebar(input: {
isFocusModeEnabled: boolean; isFocusModeEnabled: boolean;
isMobile: boolean; isMobile: boolean;
}): boolean { }): boolean {
return input.isRouteFocused && shouldShowWorkspaceScreenHeader(input); return !input.isMobile && input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
} }
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null { 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({ function WorkspaceScreenContent({
serverId, serverId,
workspaceId, workspaceId,
@@ -1890,7 +1807,6 @@ function WorkspaceScreenContent({
const isExplorerOpen = usePanelStore((state) => const isExplorerOpen = usePanelStore((state) =>
selectIsFileExplorerOpen(state, { isCompact: isMobile }), selectIsFileExplorerOpen(state, { isCompact: isMobile }),
); );
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const toggleFileExplorerForCheckout = usePanelStore( const toggleFileExplorerForCheckout = usePanelStore(
(state) => state.toggleFileExplorerForCheckout, (state) => state.toggleFileExplorerForCheckout,
); );
@@ -1907,16 +1823,6 @@ function WorkspaceScreenContent({
}; };
}, [isGitCheckout, normalizedServerId, workspaceDirectory]); }, [isGitCheckout, normalizedServerId, workspaceDirectory]);
const openExplorerForWorkspace = useCallback(() => {
if (!activeExplorerCheckout) {
return;
}
openFileExplorerForCheckout({
isCompact: isMobile,
checkout: activeExplorerCheckout,
});
}, [activeExplorerCheckout, isMobile, openFileExplorerForCheckout]);
const handleToggleExplorer = useCallback(() => { const handleToggleExplorer = useCallback(() => {
if (!activeExplorerCheckout) { if (!activeExplorerCheckout) {
return; return;
@@ -2287,9 +2193,6 @@ function WorkspaceScreenContent({
const handleOpenFileFromExplorer = useCallback( const handleOpenFileFromExplorer = useCallback(
function handleOpenFileFromExplorer(filePath: string) { function handleOpenFileFromExplorer(filePath: string) {
if (isMobile) {
showMobileAgent();
}
if (!persistenceKey) { if (!persistenceKey) {
return; return;
} }
@@ -2302,7 +2205,7 @@ function WorkspaceScreenContent({
navigateToTabId(tabId); navigateToTabId(tabId);
} }
}, },
[isMobile, navigateToTabId, openWorkspaceTabFocused, persistenceKey, showMobileAgent], [navigateToTabId, openWorkspaceTabFocused, persistenceKey],
); );
const handleOpenFileFromChat = useCallback( const handleOpenFileFromChat = useCallback(
@@ -3695,12 +3598,7 @@ function WorkspaceScreenContent({
<View style={styles.centerContent}> <View style={styles.centerContent}>
{isMobile ? ( {isMobile ? (
<MobileExplorerOpenGestureSurface <View style={styles.content}>{content}</View>
enabled={Boolean(activeExplorerCheckout)}
onOpenExplorer={openExplorerForWorkspace}
>
{content}
</MobileExplorerOpenGestureSurface>
) : ( ) : (
<View style={styles.content}>{desktopContent}</View> <View style={styles.content}>{desktopContent}</View>
)} )}