Files
paseo/packages/app/src/components/explorer-sidebar.tsx
T
Mohamed BoudraandGitHub 28a27b02d3 Prevent Android chats from freezing or going blank (#1989)
* fix(app): stabilize streamed chat rendering

Use deterministic Markdown AST keys and preserve unique row identities when an assistant message resumes after a tool. Keep native gesture and plan-card children correctly keyed to avoid unsupported Fabric reconciliation paths.

* fix(app): stabilize retained native panels

Keep retained workspace roots in a stable native order and make active
panels present in the selection commit. Hidden panels now stop expensive
subscriptions and animations without losing mounted state.

* fix(app): harden retained panel activity
2026-07-11 15:39:14 +08:00

526 lines
16 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef } from "react";
import {
View,
Text,
Pressable,
useWindowDimensions,
StyleSheet as RNStyleSheet,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import {
formatPrTabLabel,
PullRequestPane,
PullRequestPaneError,
PullRequestPaneSkeleton,
PullRequestTabIcon,
usePrPaneData,
} from "@/git/pull-request-panel";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
import {
usePanelStore,
selectIsFileExplorerOpen,
MIN_EXPLORER_SIDEBAR_WIDTH,
MAX_EXPLORER_SIDEBAR_WIDTH,
type ExplorerTab,
} from "@/stores/panel-store";
import { useToast } from "@/contexts/toast-context";
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
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";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { RetainedPanelActivity } from "@/components/retained-panel";
import { isWeb } from "@/constants/platform";
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
interface ExplorerSidebarProps {
serverId: string;
workspaceId?: string | null;
workspaceRoot: string;
isGit: boolean;
onOpenFile?: (filePath: string) => void;
}
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,
isGit,
onOpenFile,
}: ExplorerSidebarProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: true }));
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
serverId,
workspaceRoot,
isGit,
});
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
mode: "padding",
enabled: true,
});
const { gesture: closeGesture } = useCloseFileExplorerGesture();
const handleClose = useCallback(
(reason: string) => {
logExplorerSidebar("handleClose", {
reason,
isOpen,
});
showMobileAgent();
},
[isOpen, showMobileAgent],
);
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
const mobileSidebarStyle = useMemo(
() => [
{
paddingTop: insets.top,
backgroundColor: theme.colors.surfaceSidebar,
},
mobileKeyboardInsetStyle,
],
[insets.top, theme.colors.surfaceSidebar, mobileKeyboardInsetStyle],
);
return (
<RetainedPanelActivity active={isOpen}>
<MobilePanelOverlay
panel="file-explorer"
closeGesture={closeGesture}
panelStyle={mobileSidebarStyle}
>
<ExplorerSidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleHeaderClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</MobilePanelOverlay>
</RetainedPanelActivity>
);
}
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],
);
if (!isOpen) {
return null;
}
return (
<Animated.View style={desktopSidebarStyle}>
<View style={DESKTOP_SIDEBAR_BORDER_STYLE}>
<GestureDetector gesture={resizeGesture}>
<View style={RESIZE_HANDLE_STYLE} />
</GestureDetector>
<ExplorerSidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleDesktopClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile={false}
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</View>
</Animated.View>
);
}
interface ExplorerTabButtonProps {
tab: ExplorerTab;
active: boolean;
label?: string;
onTabPress: (tab: ExplorerTab) => void;
testID: string;
children?: React.ReactNode;
}
function ExplorerTabButton({
tab,
active,
label,
onTabPress,
testID,
children,
}: ExplorerTabButtonProps) {
const handlePress = useCallback(() => onTabPress(tab), [onTabPress, tab]);
const tabStyle = useMemo(() => [styles.tab, active && styles.tabActive], [active]);
const tabTextStyle = useMemo(() => [styles.tabText, active && styles.tabTextActive], [active]);
return (
<Pressable testID={testID} style={tabStyle} onPress={handlePress}>
{children}
{label !== undefined ? <Text style={tabTextStyle}>{label}</Text> : null}
</Pressable>
);
}
interface SidebarContentProps {
activeTab: ExplorerTab;
onTabPress: (tab: ExplorerTab) => void;
onClose: () => void;
serverId: string;
workspaceId?: string | null;
workspaceRoot: string;
isGit: boolean;
isMobile: boolean;
isOpen: boolean;
onOpenFile?: (filePath: string) => void;
}
function ExplorerSidebarContent({
activeTab,
onTabPress,
onClose,
serverId,
workspaceId,
workspaceRoot,
isGit,
isMobile,
isOpen,
onOpenFile,
}: SidebarContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const toast = useToast();
const padding = useWindowControlsPadding("explorerSidebar");
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
const prPane = usePrPaneData({
serverId,
cwd: workspaceRoot,
enabled: canQueryPullRequest && isOpen,
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
});
const hasPullRequest = prPane.prNumber !== null;
const showPrTab = hasPullRequest || (activeTab === "pr" && prPane.isLoading);
const requestedTab: ExplorerTab =
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
const resolvedTab: ExplorerTab = requestedTab === "pr" && !showPrTab ? "changes" : requestedTab;
const prTabLabel = formatPrTabLabel(prPane.prNumber);
const refreshGitActions = useCheckoutGitActionsStore((s) => s.refresh);
const handlePrRetry = useCallback(() => {
refreshGitActions({ serverId, cwd: workspaceRoot }).catch((error) => {
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
});
}, [refreshGitActions, serverId, t, toast, workspaceRoot]);
const workspaceAttachmentScopeKey = useMemo(
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }),
[serverId, workspaceId, workspaceRoot],
);
const headerStyle = useMemo(
() => [styles.header, { paddingRight: padding.right }],
[padding.right],
);
return (
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */}
<View style={headerStyle} testID="explorer-header">
<TitlebarDragRegion />
<View style={styles.tabsContainer}>
{isGit && (
<ExplorerTabButton
tab="changes"
active={resolvedTab === "changes"}
label={t("workspace.tabs.explorer.changes")}
onTabPress={onTabPress}
testID="explorer-tab-changes"
/>
)}
<ExplorerTabButton
tab="files"
active={resolvedTab === "files"}
label={t("workspace.tabs.explorer.files")}
onTabPress={onTabPress}
testID="explorer-tab-files"
/>
{isGit && showPrTab && (
<ExplorerTabButton
tab="pr"
active={resolvedTab === "pr"}
label={prTabLabel}
onTabPress={onTabPress}
testID="explorer-tab-pr"
>
<PullRequestTabIcon
size={13}
color={
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
</ExplorerTabButton>
)}
</View>
<View style={styles.headerRightSection}>
{isMobile && (
<Pressable onPress={onClose} style={styles.closeButton}>
<X size={18} color={theme.colors.foregroundMuted} />
</Pressable>
)}
</View>
</View>
{/* Content based on active tab */}
<View style={styles.contentArea} testID="explorer-content-area">
{resolvedTab === "changes" && (
<GitDiffPane
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
enabled={isOpen}
/>
)}
{resolvedTab === "files" && (
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
/>
)}
{resolvedTab === "pr" && (
<PrTabContent
serverId={serverId}
cwd={workspaceRoot}
prPane={prPane}
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
onRetry={handlePrRetry}
/>
)}
</View>
</View>
);
}
interface PrTabContentProps {
serverId: string;
cwd: string;
prPane: UsePrPaneDataResult;
workspaceAttachmentScopeKey: string;
onRetry: () => void;
}
function PrTabContent({
serverId,
cwd,
prPane,
workspaceAttachmentScopeKey,
onRetry,
}: PrTabContentProps) {
if (prPane.data) {
return (
<PullRequestPane
serverId={serverId}
cwd={cwd}
data={prPane.data}
activityLoading={prPane.activityLoading}
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
/>
);
}
if (prPane.error) {
return <PullRequestPaneError onRetry={onRetry} />;
}
return <PullRequestPaneSkeleton />;
}
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages.
const explorerStaticStyles = RNStyleSheet.create({
desktopSidebar: {
position: "relative" as const,
},
});
const styles = StyleSheet.create((theme) => ({
desktopSidebarBorder: {
borderLeftWidth: 1,
borderLeftColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
},
resizeHandle: {
position: "absolute",
left: -5,
top: 0,
bottom: 0,
width: 10,
zIndex: 10,
},
sidebarContent: {
flex: 1,
minHeight: 0,
overflow: "hidden",
},
header: {
position: "relative",
height: HEADER_INNER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[2],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
tabsContainer: {
flexDirection: "row",
gap: theme.spacing[1],
},
tab: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
tabActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
tabText: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
tabTextActive: {
color: theme.colors.foreground,
},
tabTextMuted: {
opacity: 0.8,
},
headerRightSection: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
closeButton: {
padding: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
contentArea: {
flex: 1,
minHeight: 0,
},
}));
const DESKTOP_SIDEBAR_BORDER_STYLE = [styles.desktopSidebarBorder, { flex: 1 }];
const RESIZE_HANDLE_STYLE = [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)];