Open the complete working comparison in a desktop workspace tab while preserving inline sidebar diffs when the tab is closed. Working and commit comparisons share one diff panel, and workspace layout is the sole owner of live tab persistence and identity. Refs #1520
602 lines
18 KiB
TypeScript
602 lines
18 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 } 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, 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 { useHasOwnedWindowChromeObstruction, WindowChromeSafeArea } from "@/utils/desktop-window";
|
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
|
import { RetainedPanelActivity } from "@/components/retained-panel";
|
|
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
|
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
|
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
|
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
|
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
|
|
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
|
import { useDraftStore } from "@/stores/draft-store";
|
|
|
|
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}
|
|
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 visibleExplorerWidth = resolveDesktopExplorerWidth({
|
|
requestedWidth: explorerWidth,
|
|
viewportWidth,
|
|
});
|
|
const startWidthRef = useRef(visibleExplorerWidth);
|
|
const resizeWidth = useSharedValue(visibleExplorerWidth);
|
|
|
|
useEffect(() => {
|
|
resizeWidth.value = visibleExplorerWidth;
|
|
}, [resizeWidth, visibleExplorerWidth]);
|
|
|
|
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 = visibleExplorerWidth;
|
|
resizeWidth.value = visibleExplorerWidth;
|
|
})
|
|
.onUpdate((event) => {
|
|
const newWidth = startWidthRef.current - event.translationX;
|
|
resizeWidth.value = resolveDesktopExplorerWidth({
|
|
requestedWidth: newWidth,
|
|
viewportWidth,
|
|
});
|
|
})
|
|
.onEnd(() => {
|
|
runOnJS(setExplorerWidth)(resizeWidth.value);
|
|
}),
|
|
[resizeWidth, setExplorerWidth, viewportWidth, visibleExplorerWidth],
|
|
);
|
|
|
|
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={[styles.desktopSidebarBorder, { flex: 1 }]}>
|
|
<SidebarResizeHandle
|
|
edge="left"
|
|
gesture={resizeGesture}
|
|
testID="explorer-sidebar-resize-handle"
|
|
/>
|
|
|
|
<ExplorerSidebarContent
|
|
activeTab={explorerTab}
|
|
onTabPress={handleTabPress}
|
|
onClose={handleDesktopClose}
|
|
serverId={serverId}
|
|
workspaceId={workspaceId}
|
|
workspaceRoot={workspaceRoot}
|
|
isGit={isGit}
|
|
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;
|
|
isOpen: boolean;
|
|
onOpenFile?: (filePath: string) => void;
|
|
}
|
|
|
|
function ExplorerSidebarContent({
|
|
activeTab,
|
|
onTabPress,
|
|
onClose,
|
|
serverId,
|
|
workspaceId,
|
|
workspaceRoot,
|
|
isGit,
|
|
isOpen,
|
|
onOpenFile,
|
|
}: SidebarContentProps) {
|
|
const { theme } = useUnistyles();
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right");
|
|
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],
|
|
);
|
|
|
|
return (
|
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
|
{/* Header with tabs and close button */}
|
|
<WindowChromeSafeArea
|
|
placement="inline"
|
|
horizontalPadding={theme.spacing[2]}
|
|
style={styles.header}
|
|
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
|
|
forge={prPane.forge}
|
|
size={13}
|
|
color={
|
|
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted
|
|
}
|
|
/>
|
|
</ExplorerTabButton>
|
|
)}
|
|
</View>
|
|
<View style={styles.headerRightSection}>
|
|
{!hasRightWindowControls && (
|
|
<Pressable
|
|
onPress={onClose}
|
|
style={styles.closeButton}
|
|
testID="explorer-close"
|
|
nativeID="explorer-close"
|
|
accessible
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("workspace.tabs.explorer.close")}
|
|
hitSlop={8}
|
|
>
|
|
{({ hovered, pressed }) => (
|
|
<X
|
|
size={18}
|
|
color={
|
|
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
|
}
|
|
/>
|
|
)}
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
</WindowChromeSafeArea>
|
|
|
|
{/* Content based on active tab */}
|
|
<View style={styles.contentArea} testID="explorer-content-area">
|
|
{resolvedTab === "changes" && (
|
|
<ChangedFilesPane
|
|
serverId={serverId}
|
|
workspaceId={workspaceId}
|
|
workspaceRoot={workspaceRoot}
|
|
isOpen={isOpen}
|
|
onOpenFile={onOpenFile}
|
|
/>
|
|
)}
|
|
{resolvedTab === "files" && (
|
|
<FilesPane
|
|
serverId={serverId}
|
|
workspaceId={workspaceId}
|
|
workspaceRoot={workspaceRoot}
|
|
onOpenFile={onOpenFile}
|
|
/>
|
|
)}
|
|
{resolvedTab === "pr" && (
|
|
<PrTabContent
|
|
serverId={serverId}
|
|
cwd={workspaceRoot}
|
|
prPane={prPane}
|
|
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
|
|
onRetry={handlePrRetry}
|
|
/>
|
|
)}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Shared add-to-chat state for the changes/files panes: both expose an "add file
|
|
* to chat" action that attaches the file to the focused chat's composer.
|
|
* Available only when a workspace with a focused chat is available.
|
|
*/
|
|
function useAddFileToChat({
|
|
serverId,
|
|
workspaceId,
|
|
}: Pick<SidebarContentProps, "serverId" | "workspaceId">) {
|
|
const workspaceKey = workspaceId
|
|
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
|
|
: null;
|
|
const layout = useWorkspaceLayoutStore((state) =>
|
|
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
|
|
);
|
|
const focusTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
|
const focusedChat = useMemo(
|
|
() => resolveFocusedChatTarget({ serverId, layout }),
|
|
[serverId, layout],
|
|
);
|
|
const addFile = useCallback(
|
|
(filePath: string) => {
|
|
if (!focusedChat || !workspaceKey) {
|
|
return;
|
|
}
|
|
void useDraftStore.getState().attachWorkspaceFile({
|
|
draftKey: focusedChat.draftKey,
|
|
attachment: createWorkspaceFileAttachment({ path: filePath }),
|
|
});
|
|
focusTab(workspaceKey, focusedChat.tabId);
|
|
},
|
|
[focusTab, focusedChat, workspaceKey],
|
|
);
|
|
return { addFile, canAddToChat: focusedChat !== null };
|
|
}
|
|
|
|
function ChangedFilesPane({
|
|
serverId,
|
|
workspaceId,
|
|
workspaceRoot,
|
|
isOpen,
|
|
onOpenFile,
|
|
}: Pick<
|
|
SidebarContentProps,
|
|
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
|
|
>) {
|
|
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
|
return (
|
|
<GitDiffPane
|
|
serverId={serverId}
|
|
workspaceId={workspaceId}
|
|
cwd={workspaceRoot}
|
|
enabled={isOpen}
|
|
onOpenFile={onOpenFile}
|
|
onAddToChat={canAddToChat ? addFile : undefined}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function FilesPane({
|
|
serverId,
|
|
workspaceId,
|
|
workspaceRoot,
|
|
onOpenFile,
|
|
}: Pick<SidebarContentProps, "serverId" | "workspaceId" | "workspaceRoot" | "onOpenFile">) {
|
|
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
|
return (
|
|
<FileExplorerPane
|
|
serverId={serverId}
|
|
workspaceId={workspaceId}
|
|
workspaceRoot={workspaceRoot}
|
|
onOpenFile={onOpenFile}
|
|
onAddToChat={canAddToChat ? addFile : undefined}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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,
|
|
},
|
|
sidebarContent: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
overflow: "hidden",
|
|
},
|
|
header: {
|
|
position: "relative",
|
|
height: HEADER_INNER_HEIGHT,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
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,
|
|
},
|
|
}));
|