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:
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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];
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user