* fix(explorer): prevent persisted trees from crashing startup Persisted expansion restored every directory and recursively flattened the resulting tree during render. Limit automatic restoration to five rendered directory levels and flatten iteratively so deep or broad workspaces cannot exhaust the JavaScript stack. * fix(explorer): preserve expansion changes during restore Reconcile completed restoration against the current expansion set atomically so folder interactions made while directory requests are in flight are not overwritten. * fix(explorer): show hidden files before restoration * fix(explorer): apply directory toggles atomically
1180 lines
33 KiB
TypeScript
1180 lines
33 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, type ReactElement, type RefObject } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
FlatList,
|
|
ListRenderItemInfo,
|
|
Pressable,
|
|
Text,
|
|
View,
|
|
type PressableStateCallbackType,
|
|
type StyleProp,
|
|
type ViewStyle,
|
|
} from "react-native";
|
|
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
|
import * as Clipboard from "expo-clipboard";
|
|
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
|
|
import { MaterialFileIcon } from "@/components/material-file-icon";
|
|
import {
|
|
TreeChevron,
|
|
TreeIndentGuides,
|
|
treeRowPaddingLeft,
|
|
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
|
} from "@/components/tree-primitives";
|
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
import type { Theme } from "@/styles/theme";
|
|
import type {
|
|
AgentFileExplorerState,
|
|
ExplorerDirectory,
|
|
ExplorerEntry,
|
|
} from "@/stores/session-store";
|
|
import { useSessionStore } from "@/stores/session-store";
|
|
import { FileActionsMenu } from "@/components/file-actions-menu";
|
|
import { useFileDownload } from "@/hooks/use-file-download";
|
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
|
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
|
import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store";
|
|
import { formatTimeAgo } from "@/utils/time";
|
|
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
|
import { isHiddenExplorerPath } from "@/file-explorer/visibility";
|
|
import {
|
|
flattenExplorerTree,
|
|
reconcileRestoredExpandedPaths,
|
|
restoreExpandedDirectories,
|
|
setExpandedDirectoryPath,
|
|
showHiddenFilesAndRestoreExpandedDirectories,
|
|
type ExplorerTreeRow,
|
|
} from "@/file-explorer/tree";
|
|
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
|
|
|
|
const SORT_OPTIONS: { value: SortOption }[] = [
|
|
{ value: "name" },
|
|
{ value: "modified" },
|
|
{ value: "size" },
|
|
];
|
|
|
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
|
color: theme.colors.foregroundMuted,
|
|
});
|
|
|
|
function formatFileSize({ size }: { size: number }): string {
|
|
if (size < 1024) {
|
|
return `${size} B`;
|
|
}
|
|
if (size < 1024 * 1024) {
|
|
return `${(size / 1024).toFixed(1)} KB`;
|
|
}
|
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
interface TreeRowItemProps {
|
|
serverId: string;
|
|
workspaceId?: string | null;
|
|
entry: ExplorerEntry;
|
|
depth: number;
|
|
isExpanded: boolean;
|
|
isSelected: boolean;
|
|
loading: boolean;
|
|
onEntryPress: (entry: ExplorerEntry) => void;
|
|
onCopyPath: (path: string) => void;
|
|
onDownloadEntry: (entry: ExplorerEntry) => void;
|
|
onAddToChat?: (path: string) => void;
|
|
testID?: string;
|
|
}
|
|
|
|
function sortTriggerStyle({
|
|
hovered,
|
|
pressed,
|
|
}: PressableStateCallbackType & { hovered?: boolean }) {
|
|
return [styles.sortTrigger, (Boolean(hovered) || pressed) && styles.sortTriggerHovered];
|
|
}
|
|
|
|
function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
|
|
return [styles.iconButton, (Boolean(hovered) || pressed) && styles.iconButtonHovered];
|
|
}
|
|
|
|
function treeRowKeyExtractor(row: ExplorerTreeRow) {
|
|
return row.entry.path;
|
|
}
|
|
|
|
function TreeRowItem({
|
|
serverId,
|
|
workspaceId,
|
|
entry,
|
|
depth,
|
|
isExpanded,
|
|
isSelected,
|
|
loading,
|
|
onEntryPress,
|
|
onCopyPath,
|
|
onDownloadEntry,
|
|
onAddToChat,
|
|
testID,
|
|
}: TreeRowItemProps) {
|
|
const { t } = useTranslation();
|
|
const isDirectory = entry.kind === "directory";
|
|
const dragSourceRef = useWorkspaceFileDragSource({
|
|
enabled: !isDirectory,
|
|
serverId,
|
|
workspaceId,
|
|
path: entry.path,
|
|
});
|
|
|
|
const handlePress = useCallback(() => {
|
|
onEntryPress(entry);
|
|
}, [onEntryPress, entry]);
|
|
|
|
const pressableStyle = useCallback(
|
|
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
styles.entryRow,
|
|
{ paddingLeft: treeRowPaddingLeft(depth) },
|
|
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
|
|
],
|
|
[depth, isSelected],
|
|
);
|
|
|
|
const handleCopy = useCallback(() => {
|
|
onCopyPath(entry.path);
|
|
}, [onCopyPath, entry.path]);
|
|
|
|
const handleDownload = useCallback(() => {
|
|
onDownloadEntry(entry);
|
|
}, [onDownloadEntry, entry]);
|
|
|
|
const handleAddToChat = useCallback(() => {
|
|
onAddToChat?.(entry.path);
|
|
}, [onAddToChat, entry.path]);
|
|
|
|
const metaHeader = useMemo(
|
|
() => (
|
|
<View style={styles.contextMetaBlock}>
|
|
<View style={styles.contextMetaRow}>
|
|
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
|
{t("workspace.fileExplorer.context.size")}
|
|
</Text>
|
|
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
|
{formatFileSize({ size: entry.size })}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.contextMetaRow}>
|
|
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
|
{t("workspace.fileExplorer.context.modified")}
|
|
</Text>
|
|
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
|
{formatTimeAgo(new Date(entry.modifiedAt))}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
),
|
|
[entry.modifiedAt, entry.size, t],
|
|
);
|
|
|
|
return (
|
|
<Pressable onPress={handlePress} style={pressableStyle} testID={testID}>
|
|
<TreeIndentGuides depth={depth} />
|
|
<View ref={dragSourceRef} style={styles.entryInfo}>
|
|
<View style={styles.entryIcon}>
|
|
{(() => {
|
|
if (!isDirectory) {
|
|
return <MaterialFileIcon fileName={entry.name} size={16} />;
|
|
}
|
|
if (loading) {
|
|
return <ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />;
|
|
}
|
|
return <TreeChevron expanded={isExpanded} />;
|
|
})()}
|
|
</View>
|
|
<Text style={styles.entryName} numberOfLines={1}>
|
|
{entry.name}
|
|
</Text>
|
|
</View>
|
|
<FileActionsMenu
|
|
fileKind={entry.kind}
|
|
onCopyPath={handleCopy}
|
|
onDownload={handleDownload}
|
|
onAddToChat={onAddToChat ? handleAddToChat : undefined}
|
|
header={metaHeader}
|
|
accessibilityLabel={t("workspace.fileActions.moreActions")}
|
|
testIDPrefix={testID}
|
|
/>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
interface FileExplorerPaneProps {
|
|
serverId: string;
|
|
workspaceId?: string | null;
|
|
workspaceRoot: string;
|
|
onOpenFile?: (filePath: string) => void;
|
|
onAddToChat?: (path: string) => void;
|
|
}
|
|
|
|
export function FileExplorerPane({
|
|
serverId,
|
|
workspaceId,
|
|
workspaceRoot,
|
|
onOpenFile,
|
|
onAddToChat,
|
|
}: FileExplorerPaneProps) {
|
|
const { t } = useTranslation();
|
|
|
|
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
|
const workspaceStateKey = useMemo(
|
|
() =>
|
|
buildWorkspaceExplorerStateKey({
|
|
workspaceId,
|
|
workspaceRoot: normalizedWorkspaceRoot,
|
|
}),
|
|
[normalizedWorkspaceRoot, workspaceId],
|
|
);
|
|
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
|
|
const explorerState = useSessionStore((state) =>
|
|
workspaceStateKey && state.sessions[serverId]
|
|
? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey)
|
|
: undefined,
|
|
);
|
|
|
|
const { requestDirectoryListing, selectExplorerEntry } = useFileExplorerActions({
|
|
serverId,
|
|
workspaceId,
|
|
workspaceRoot: normalizedWorkspaceRoot,
|
|
});
|
|
const downloadFile = useFileDownload({
|
|
serverId,
|
|
workspaceId,
|
|
workspaceRoot: normalizedWorkspaceRoot,
|
|
});
|
|
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
|
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
|
|
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
|
const toggleExplorerShowHiddenFiles = usePanelStore(
|
|
(state) => state.toggleExplorerShowHiddenFiles,
|
|
);
|
|
const expandedPathsArray = usePanelStore((state) =>
|
|
workspaceStateKey ? state.expandedPathsByWorkspace[workspaceStateKey] : undefined,
|
|
);
|
|
const setExpandedPathsForWorkspace = usePanelStore((state) => state.setExpandedPathsForWorkspace);
|
|
const expandedPaths = useMemo(
|
|
() => new Set(expandedPathsArray && expandedPathsArray.length > 0 ? expandedPathsArray : ["."]),
|
|
[expandedPathsArray],
|
|
);
|
|
|
|
const explorerDerived = useMemo(() => deriveExplorerFields(explorerState), [explorerState]);
|
|
const { directories, pendingRequest, isExplorerLoading, error, selectedEntryPath } =
|
|
explorerDerived;
|
|
|
|
const isDirectoryLoading = useCallback(
|
|
(path: string) => isPendingListForPath({ isExplorerLoading, pendingRequest, path }),
|
|
[isExplorerLoading, pendingRequest],
|
|
);
|
|
|
|
const treeListRef = useRef<FlatList<ExplorerTreeRow>>(null);
|
|
|
|
const hasInitializedRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
hasInitializedRef.current = false;
|
|
}, [workspaceStateKey]);
|
|
|
|
useEffect(() => {
|
|
void initializeExplorer({
|
|
hasWorkspaceScope,
|
|
hasInitializedRef,
|
|
workspaceStateKey,
|
|
persistedExpandedPaths: expandedPaths,
|
|
showHiddenFiles,
|
|
requestDirectoryListing,
|
|
setExpandedPathsForWorkspace,
|
|
});
|
|
}, [
|
|
expandedPaths,
|
|
hasWorkspaceScope,
|
|
requestDirectoryListing,
|
|
setExpandedPathsForWorkspace,
|
|
showHiddenFiles,
|
|
workspaceStateKey,
|
|
]);
|
|
|
|
const handleToggleDirectory = useCallback(
|
|
(entry: ExplorerEntry) =>
|
|
toggleDirectory({
|
|
entry,
|
|
workspaceStateKey,
|
|
expandedPaths,
|
|
directories,
|
|
requestDirectoryListing,
|
|
setExpandedPathsForWorkspace,
|
|
}),
|
|
[
|
|
workspaceStateKey,
|
|
expandedPaths,
|
|
directories,
|
|
requestDirectoryListing,
|
|
setExpandedPathsForWorkspace,
|
|
],
|
|
);
|
|
|
|
const handleOpenFile = useCallback(
|
|
(entry: ExplorerEntry) => {
|
|
if (!hasWorkspaceScope) {
|
|
return;
|
|
}
|
|
selectExplorerEntry(entry.path);
|
|
onOpenFile?.(entry.path);
|
|
},
|
|
[hasWorkspaceScope, onOpenFile, selectExplorerEntry],
|
|
);
|
|
|
|
const handleEntryPress = useCallback(
|
|
(entry: ExplorerEntry) => {
|
|
if (entry.kind === "directory") {
|
|
handleToggleDirectory(entry);
|
|
return;
|
|
}
|
|
handleOpenFile(entry);
|
|
},
|
|
[handleOpenFile, handleToggleDirectory],
|
|
);
|
|
|
|
const handleCopyPath = useCallback(
|
|
async (path: string) => {
|
|
await Clipboard.setStringAsync(
|
|
buildAbsoluteExplorerPath({
|
|
workspaceRoot: normalizedWorkspaceRoot,
|
|
entryPath: path,
|
|
}),
|
|
);
|
|
},
|
|
[normalizedWorkspaceRoot],
|
|
);
|
|
|
|
const handleDownloadEntry = useCallback(
|
|
(entry: ExplorerEntry) => {
|
|
if (entry.kind !== "file") {
|
|
return;
|
|
}
|
|
downloadFile({ fileName: entry.name, path: entry.path });
|
|
},
|
|
[downloadFile],
|
|
);
|
|
|
|
const handleSortCycle = useCallback(() => {
|
|
const currentIndex = SORT_OPTIONS.findIndex((opt) => opt.value === sortOption);
|
|
const nextIndex = (currentIndex + 1) % SORT_OPTIONS.length;
|
|
setSortOption(SORT_OPTIONS[nextIndex].value);
|
|
}, [sortOption, setSortOption]);
|
|
|
|
const handleToggleHiddenFiles = useCallback(() => {
|
|
const willShow = !usePanelStore.getState().explorerShowHiddenFiles;
|
|
if (!willShow) {
|
|
toggleExplorerShowHiddenFiles();
|
|
return;
|
|
}
|
|
const rootDirectory = directories.get(".");
|
|
if (!rootDirectory || !workspaceStateKey) {
|
|
toggleExplorerShowHiddenFiles();
|
|
return;
|
|
}
|
|
void showHiddenFilesAndRestoreExpandedDirectories({
|
|
rootDirectory,
|
|
persistedExpandedPaths: expandedPaths,
|
|
showHiddenFiles: toggleExplorerShowHiddenFiles,
|
|
requestDirectoryListing: (path) =>
|
|
requestDirectoryListing(path, {
|
|
recordHistory: false,
|
|
setCurrentPath: false,
|
|
}),
|
|
}).then((restoredPaths) => {
|
|
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
|
|
reconcileRestoredExpandedPaths({
|
|
persistedExpandedPaths: expandedPaths,
|
|
currentExpandedPaths: new Set(currentPaths),
|
|
restoredExpandedPaths: restoredPaths,
|
|
}),
|
|
);
|
|
return null;
|
|
});
|
|
}, [
|
|
directories,
|
|
expandedPaths,
|
|
requestDirectoryListing,
|
|
setExpandedPathsForWorkspace,
|
|
toggleExplorerShowHiddenFiles,
|
|
workspaceStateKey,
|
|
]);
|
|
|
|
const refreshExplorer = useCallback(
|
|
() =>
|
|
refreshExplorerDirectories({
|
|
hasWorkspaceScope,
|
|
expandedPaths,
|
|
requestDirectoryListing,
|
|
}),
|
|
[expandedPaths, hasWorkspaceScope, requestDirectoryListing],
|
|
);
|
|
const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({
|
|
queryKey: ["fileExplorerRefresh", serverId, workspaceStateKey],
|
|
queryFn: refreshExplorer,
|
|
enabled: false,
|
|
});
|
|
|
|
const handleRefresh = useCallback(() => {
|
|
void refetchExplorer();
|
|
}, [refetchExplorer]);
|
|
|
|
const sortLabels = useMemo(
|
|
() => ({
|
|
name: t("workspace.fileExplorer.sort.name"),
|
|
modified: t("workspace.fileExplorer.sort.modified"),
|
|
size: t("workspace.fileExplorer.sort.size"),
|
|
}),
|
|
[t],
|
|
);
|
|
const currentSortLabel = resolveCurrentSortLabel(sortOption, sortLabels);
|
|
|
|
const treeRows = useMemo(
|
|
() => flattenExplorerTree({ directories, expandedPaths, sortOption, showHiddenFiles }),
|
|
[directories, expandedPaths, showHiddenFiles, sortOption],
|
|
);
|
|
|
|
const showInitialLoading = resolveShowInitialLoading({
|
|
directories,
|
|
isExplorerLoading,
|
|
pendingRequest,
|
|
});
|
|
const showBackFromError = Boolean(error && selectedEntryPath);
|
|
const errorRecoveryPath = useMemo(() => getErrorRecoveryPath(explorerState), [explorerState]);
|
|
|
|
const renderTreeRow = useCallback(
|
|
(info: ListRenderItemInfo<ExplorerTreeRow>) => (
|
|
<TreeRowDispatcher
|
|
serverId={serverId}
|
|
workspaceId={workspaceId}
|
|
info={info}
|
|
expandedPaths={expandedPaths}
|
|
selectedEntryPath={selectedEntryPath}
|
|
isDirectoryLoading={isDirectoryLoading}
|
|
onEntryPress={handleEntryPress}
|
|
onCopyPath={handleCopyPath}
|
|
onDownloadEntry={handleDownloadEntry}
|
|
onAddToChat={onAddToChat}
|
|
/>
|
|
),
|
|
[
|
|
expandedPaths,
|
|
handleEntryPress,
|
|
handleCopyPath,
|
|
handleDownloadEntry,
|
|
isDirectoryLoading,
|
|
selectedEntryPath,
|
|
onAddToChat,
|
|
serverId,
|
|
workspaceId,
|
|
],
|
|
);
|
|
|
|
const handleBackFromError = useCallback(() => {
|
|
if (!hasWorkspaceScope) {
|
|
return;
|
|
}
|
|
selectExplorerEntry(null);
|
|
void requestDirectoryListing(errorRecoveryPath, {
|
|
recordHistory: false,
|
|
setCurrentPath: true,
|
|
});
|
|
}, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]);
|
|
|
|
const handleRetry = useCallback(() => {
|
|
void requestDirectoryListing(".", {
|
|
recordHistory: false,
|
|
setCurrentPath: false,
|
|
});
|
|
}, [requestDirectoryListing]);
|
|
|
|
if (!hasWorkspaceScope) {
|
|
return (
|
|
<View style={styles.centerState}>
|
|
<Text style={styles.errorText}>{t("workspace.fileExplorer.states.unavailable")}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<FileExplorerPaneContent
|
|
error={error}
|
|
showInitialLoading={showInitialLoading}
|
|
showBackFromError={showBackFromError}
|
|
treeRows={treeRows}
|
|
currentSortLabel={currentSortLabel}
|
|
isRefreshFetching={isRefreshFetching}
|
|
treeListRef={treeListRef}
|
|
renderTreeRow={renderTreeRow}
|
|
handleSortCycle={handleSortCycle}
|
|
handleToggleHiddenFiles={handleToggleHiddenFiles}
|
|
handleRefresh={handleRefresh}
|
|
handleBackFromError={handleBackFromError}
|
|
handleRetry={handleRetry}
|
|
sortTriggerStyle={sortTriggerStyle}
|
|
iconButtonStyle={iconButtonStyle}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
interface FileExplorerPaneContentProps {
|
|
error: string | null;
|
|
showInitialLoading: boolean;
|
|
showBackFromError: boolean;
|
|
treeRows: ExplorerTreeRow[];
|
|
currentSortLabel: string;
|
|
isRefreshFetching: boolean;
|
|
treeListRef: RefObject<FlatList<ExplorerTreeRow> | null>;
|
|
renderTreeRow: (info: ListRenderItemInfo<ExplorerTreeRow>) => ReactElement;
|
|
handleSortCycle: () => void;
|
|
handleToggleHiddenFiles: () => void;
|
|
handleRefresh: () => void;
|
|
handleBackFromError: () => void;
|
|
handleRetry: () => void;
|
|
sortTriggerStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
|
|
iconButtonStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
|
|
}
|
|
|
|
function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
|
const { theme } = useUnistyles();
|
|
const { t } = useTranslation();
|
|
const {
|
|
error,
|
|
showInitialLoading,
|
|
showBackFromError,
|
|
treeRows,
|
|
currentSortLabel,
|
|
isRefreshFetching,
|
|
treeListRef,
|
|
renderTreeRow,
|
|
handleSortCycle,
|
|
handleToggleHiddenFiles,
|
|
handleRefresh,
|
|
handleBackFromError,
|
|
handleRetry,
|
|
sortTriggerStyle: sortTriggerStyleProp,
|
|
iconButtonStyle: iconButtonStyleProp,
|
|
} = props;
|
|
|
|
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
|
|
|
|
const hiddenFilesToggleAccessibilityLabel = showHiddenFiles
|
|
? t("workspace.fileExplorer.actions.hideHiddenFiles")
|
|
: t("workspace.fileExplorer.actions.showHiddenFiles");
|
|
const emptyLabel = showHiddenFiles
|
|
? t("workspace.fileExplorer.empty.noFiles")
|
|
: t("workspace.fileExplorer.empty.noVisibleFiles");
|
|
const hiddenFilesToggleStyle = useCallback(
|
|
(state: PressableStateCallbackType) => [
|
|
iconButtonStyleProp(state),
|
|
!showHiddenFiles && styles.iconButtonActive,
|
|
],
|
|
[showHiddenFiles, iconButtonStyleProp],
|
|
);
|
|
const hiddenFilesToggleAccessibilityState = useMemo(
|
|
() => ({ selected: !showHiddenFiles }),
|
|
[showHiddenFiles],
|
|
);
|
|
|
|
if (error) {
|
|
return (
|
|
<View style={styles.centerState}>
|
|
<Text style={styles.errorText}>{error}</Text>
|
|
<View style={styles.errorActions}>
|
|
{showBackFromError ? (
|
|
<Pressable style={styles.retryButton} onPress={handleBackFromError}>
|
|
<Text style={styles.retryButtonText}>{t("workspace.fileExplorer.actions.back")}</Text>
|
|
</Pressable>
|
|
) : null}
|
|
<Pressable style={styles.retryButton} onPress={handleRetry}>
|
|
<Text style={styles.retryButtonText}>{t("workspace.fileExplorer.actions.retry")}</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (showInitialLoading) {
|
|
return (
|
|
<View style={styles.centerState}>
|
|
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
|
<Text style={styles.loadingText}>{t("workspace.fileExplorer.states.loading")}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={[styles.treePane, styles.treePaneFill]}>
|
|
<View style={styles.paneHeader} testID="files-pane-header">
|
|
<Pressable
|
|
onPress={handleSortCycle}
|
|
style={sortTriggerStyleProp}
|
|
testID="files-sort-trigger"
|
|
>
|
|
<Text style={styles.sortTriggerText} testID="files-sort-label">
|
|
{currentSortLabel}
|
|
</Text>
|
|
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
|
</Pressable>
|
|
<View style={styles.headerActions}>
|
|
<Pressable
|
|
onPress={handleToggleHiddenFiles}
|
|
hitSlop={8}
|
|
style={hiddenFilesToggleStyle}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
|
|
accessibilityState={hiddenFilesToggleAccessibilityState}
|
|
testID="files-hidden-toggle"
|
|
>
|
|
{showHiddenFiles ? (
|
|
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
) : (
|
|
<EyeOff size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
)}
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={handleRefresh}
|
|
disabled={isRefreshFetching}
|
|
hitSlop={8}
|
|
style={iconButtonStyleProp}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={
|
|
isRefreshFetching
|
|
? t("workspace.fileExplorer.actions.refreshing")
|
|
: t("workspace.fileExplorer.actions.refresh")
|
|
}
|
|
testID="files-refresh"
|
|
>
|
|
<View style={styles.refreshIcon}>
|
|
{isRefreshFetching ? (
|
|
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
) : (
|
|
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
)}
|
|
</View>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
{treeRows.length === 0 ? (
|
|
<View style={styles.centerState}>
|
|
<Text style={styles.emptyText}>{emptyLabel}</Text>
|
|
</View>
|
|
) : (
|
|
<FlatList
|
|
ref={treeListRef}
|
|
style={styles.treeList}
|
|
data={treeRows}
|
|
renderItem={renderTreeRow}
|
|
keyExtractor={treeRowKeyExtractor}
|
|
testID="file-explorer-tree-scroll"
|
|
contentContainerStyle={styles.entriesContent}
|
|
showsVerticalScrollIndicator
|
|
initialNumToRender={24}
|
|
maxToRenderPerBatch={40}
|
|
windowSize={12}
|
|
/>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function deriveExplorerFields(state: AgentFileExplorerState | undefined) {
|
|
return {
|
|
directories:
|
|
state?.directories ?? new Map<string, { path: string; entries: ExplorerEntry[] }>(),
|
|
pendingRequest: state?.pendingRequest ?? null,
|
|
isExplorerLoading: state?.isLoading ?? false,
|
|
error: state?.lastError ?? null,
|
|
selectedEntryPath: state?.selectedEntryPath ?? null,
|
|
};
|
|
}
|
|
|
|
function isPendingListForPath({
|
|
isExplorerLoading,
|
|
pendingRequest,
|
|
path,
|
|
}: {
|
|
isExplorerLoading: boolean;
|
|
pendingRequest: AgentFileExplorerState["pendingRequest"] | null;
|
|
path: string;
|
|
}): boolean {
|
|
return Boolean(
|
|
isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === path,
|
|
);
|
|
}
|
|
|
|
function resolveShowInitialLoading({
|
|
directories,
|
|
isExplorerLoading,
|
|
pendingRequest,
|
|
}: {
|
|
directories: Map<string, unknown>;
|
|
isExplorerLoading: boolean;
|
|
pendingRequest: AgentFileExplorerState["pendingRequest"] | null;
|
|
}): boolean {
|
|
if (directories.has(".")) {
|
|
return false;
|
|
}
|
|
return Boolean(
|
|
isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === ".",
|
|
);
|
|
}
|
|
|
|
function resolveCurrentSortLabel(
|
|
sortOption: SortOption,
|
|
labels: Record<SortOption, string>,
|
|
): string {
|
|
return labels[sortOption] ?? labels.name;
|
|
}
|
|
|
|
function toggleDirectory({
|
|
entry,
|
|
workspaceStateKey,
|
|
expandedPaths,
|
|
directories,
|
|
requestDirectoryListing,
|
|
setExpandedPathsForWorkspace,
|
|
}: {
|
|
entry: ExplorerEntry;
|
|
workspaceStateKey: string | null;
|
|
expandedPaths: Set<string>;
|
|
directories: Map<string, ExplorerDirectory>;
|
|
requestDirectoryListing: (
|
|
path: string,
|
|
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
|
) => Promise<ExplorerDirectory | null>;
|
|
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
|
|
}): void {
|
|
if (!workspaceStateKey) {
|
|
return;
|
|
}
|
|
const isExpanded = expandedPaths.has(entry.path);
|
|
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
|
|
setExpandedDirectoryPath({
|
|
currentExpandedPaths: currentPaths,
|
|
directoryPath: entry.path,
|
|
expanded: !isExpanded,
|
|
}),
|
|
);
|
|
if (!isExpanded && !directories.has(entry.path)) {
|
|
void requestDirectoryListing(entry.path, {
|
|
recordHistory: false,
|
|
setCurrentPath: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
function TreeRowDispatcher({
|
|
serverId,
|
|
workspaceId,
|
|
info,
|
|
expandedPaths,
|
|
selectedEntryPath,
|
|
isDirectoryLoading,
|
|
onEntryPress,
|
|
onCopyPath,
|
|
onDownloadEntry,
|
|
onAddToChat,
|
|
}: {
|
|
serverId: string;
|
|
workspaceId?: string | null;
|
|
info: ListRenderItemInfo<ExplorerTreeRow>;
|
|
expandedPaths: Set<string>;
|
|
selectedEntryPath: string | null;
|
|
isDirectoryLoading: (path: string) => boolean;
|
|
onEntryPress: (entry: ExplorerEntry) => void;
|
|
onCopyPath: (path: string) => void | Promise<void>;
|
|
onDownloadEntry: (entry: ExplorerEntry) => void;
|
|
onAddToChat?: (path: string) => void;
|
|
}) {
|
|
const entry = info.item.entry;
|
|
const depth = info.item.depth;
|
|
const isDirectory = entry.kind === "directory";
|
|
const isExpanded = isDirectory && expandedPaths.has(entry.path);
|
|
const isSelected = selectedEntryPath === entry.path;
|
|
const loading = isDirectory && isDirectoryLoading(entry.path);
|
|
|
|
return (
|
|
<TreeRowItem
|
|
serverId={serverId}
|
|
workspaceId={workspaceId}
|
|
entry={entry}
|
|
depth={depth}
|
|
isExpanded={isExpanded}
|
|
isSelected={isSelected}
|
|
loading={loading}
|
|
onEntryPress={onEntryPress}
|
|
onCopyPath={onCopyPath}
|
|
onDownloadEntry={onDownloadEntry}
|
|
onAddToChat={onAddToChat}
|
|
testID={`file-explorer-row-${info.index}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
async function initializeExplorer({
|
|
hasWorkspaceScope,
|
|
hasInitializedRef,
|
|
workspaceStateKey,
|
|
persistedExpandedPaths,
|
|
showHiddenFiles,
|
|
requestDirectoryListing,
|
|
setExpandedPathsForWorkspace,
|
|
}: {
|
|
hasWorkspaceScope: boolean;
|
|
hasInitializedRef: RefObject<boolean>;
|
|
workspaceStateKey: string | null;
|
|
persistedExpandedPaths: ReadonlySet<string>;
|
|
showHiddenFiles: boolean;
|
|
requestDirectoryListing: (
|
|
path: string,
|
|
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
|
) => Promise<ExplorerDirectory | null>;
|
|
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
|
|
}): Promise<void> {
|
|
if (!hasWorkspaceScope || hasInitializedRef.current) {
|
|
return;
|
|
}
|
|
hasInitializedRef.current = true;
|
|
const rootDirectory = await requestDirectoryListing(".", {
|
|
recordHistory: false,
|
|
setCurrentPath: false,
|
|
});
|
|
if (!rootDirectory) {
|
|
hasInitializedRef.current = false;
|
|
return;
|
|
}
|
|
if (!workspaceStateKey) {
|
|
return;
|
|
}
|
|
|
|
const restoredPaths = await restoreExpandedDirectories({
|
|
rootDirectory,
|
|
persistedExpandedPaths,
|
|
showHiddenFiles,
|
|
requestDirectoryListing: (path) =>
|
|
requestDirectoryListing(path, {
|
|
recordHistory: false,
|
|
setCurrentPath: false,
|
|
}),
|
|
});
|
|
const hiddenPersistedPaths = showHiddenFiles
|
|
? []
|
|
: Array.from(persistedExpandedPaths).filter(isHiddenExplorerPath);
|
|
const restoredPathsWithHidden = [...restoredPaths, ...hiddenPersistedPaths];
|
|
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
|
|
reconcileRestoredExpandedPaths({
|
|
persistedExpandedPaths,
|
|
currentExpandedPaths: new Set(currentPaths),
|
|
restoredExpandedPaths: restoredPathsWithHidden,
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function refreshExplorerDirectories({
|
|
hasWorkspaceScope,
|
|
expandedPaths,
|
|
requestDirectoryListing,
|
|
}: {
|
|
hasWorkspaceScope: boolean;
|
|
expandedPaths: Set<string>;
|
|
requestDirectoryListing: (
|
|
path: string,
|
|
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
|
) => Promise<ExplorerDirectory | null>;
|
|
}): Promise<null> {
|
|
if (!hasWorkspaceScope) {
|
|
return null;
|
|
}
|
|
const showHiddenFiles = usePanelStore.getState().explorerShowHiddenFiles;
|
|
const directoryPaths = Array.from(expandedPaths).filter(
|
|
(path) => showHiddenFiles || !isHiddenExplorerPath(path),
|
|
);
|
|
if (!directoryPaths.includes(".")) {
|
|
directoryPaths.unshift(".");
|
|
}
|
|
await Promise.all(
|
|
directoryPaths.map((path) =>
|
|
requestDirectoryListing(path, {
|
|
recordHistory: false,
|
|
setCurrentPath: false,
|
|
}),
|
|
),
|
|
);
|
|
return null;
|
|
}
|
|
|
|
function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string {
|
|
if (!state) {
|
|
return ".";
|
|
}
|
|
|
|
const currentHistoryPath =
|
|
state.history.length > 0 ? state.history[state.history.length - 1] : null;
|
|
const candidate = currentHistoryPath ?? state.lastVisitedPath ?? state.currentPath;
|
|
|
|
if (!candidate || candidate.length === 0) {
|
|
return ".";
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: theme.colors.surfaceSidebar,
|
|
},
|
|
desktopSplit: {
|
|
flex: 1,
|
|
flexDirection: "row",
|
|
minHeight: 0,
|
|
},
|
|
treePane: {
|
|
minWidth: 0,
|
|
position: "relative",
|
|
},
|
|
treePaneFill: {
|
|
flex: 1,
|
|
},
|
|
treePaneWithPreview: {
|
|
flex: 0,
|
|
flexGrow: 0,
|
|
flexShrink: 0,
|
|
borderLeftWidth: 1,
|
|
borderLeftColor: theme.colors.border,
|
|
},
|
|
splitResizeHandle: {
|
|
position: "absolute",
|
|
left: -5,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: 10,
|
|
zIndex: 20,
|
|
},
|
|
previewPane: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
paneHeader: {
|
|
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
paddingRight: theme.spacing[3],
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: theme.colors.border,
|
|
},
|
|
sortTrigger: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: theme.spacing[1],
|
|
marginLeft: theme.spacing[3] - theme.spacing[1],
|
|
paddingHorizontal: theme.spacing[1],
|
|
height: 24,
|
|
borderRadius: theme.borderRadius.base,
|
|
},
|
|
sortTriggerHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
sortTriggerText: {
|
|
fontSize: theme.fontSize.xs,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
headerActions: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
},
|
|
treeList: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
},
|
|
entriesContent: {
|
|
paddingTop: theme.spacing[2],
|
|
paddingBottom: theme.spacing[4],
|
|
},
|
|
centerState: {
|
|
flex: 1,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: theme.spacing[2],
|
|
padding: theme.spacing[4],
|
|
},
|
|
loadingText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
errorText: {
|
|
color: theme.colors.destructive,
|
|
fontSize: theme.fontSize.base,
|
|
textAlign: "center",
|
|
},
|
|
retryButton: {
|
|
borderRadius: theme.borderRadius.full,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[1],
|
|
},
|
|
retryButtonText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.semibold,
|
|
},
|
|
errorActions: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
emptyText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.base,
|
|
textAlign: "center",
|
|
},
|
|
binaryMetaText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
entryRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
|
paddingRight: theme.spacing[3],
|
|
},
|
|
entryRowActive: {
|
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
},
|
|
entryInfo: {
|
|
flex: 1,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
minWidth: 0,
|
|
},
|
|
entryIcon: {
|
|
flexShrink: 0,
|
|
},
|
|
entryName: {
|
|
flex: 1,
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
contextMetaBlock: {
|
|
paddingVertical: theme.spacing[1],
|
|
},
|
|
contextMetaRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
minHeight: 32,
|
|
paddingHorizontal: theme.spacing[3],
|
|
},
|
|
contextMetaLabel: {
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.foregroundMuted,
|
|
flexShrink: 0,
|
|
},
|
|
contextMetaValue: {
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.foreground,
|
|
fontWeight: theme.fontWeight.medium,
|
|
flex: 1,
|
|
minWidth: 0,
|
|
textAlign: "right",
|
|
},
|
|
previewHeaderText: {
|
|
flex: 1,
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
},
|
|
iconButton: {
|
|
width: 22,
|
|
height: 22,
|
|
borderRadius: theme.borderRadius.md,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
iconButtonHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
iconButtonActive: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
refreshIcon: {
|
|
width: 16,
|
|
height: 16,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
previewContent: {
|
|
flex: 1,
|
|
},
|
|
previewScrollContainer: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
position: "relative",
|
|
},
|
|
previewCodeScrollContent: {
|
|
paddingTop: theme.spacing[3],
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingBottom: theme.spacing[3] + theme.spacing[2],
|
|
},
|
|
codeText: {
|
|
color: theme.colors.foreground,
|
|
fontFamily: theme.fontFamily.mono,
|
|
fontSize: theme.fontSize.code,
|
|
flexShrink: 0,
|
|
},
|
|
previewImageScrollContent: {
|
|
flex: 1,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
padding: theme.spacing[3],
|
|
},
|
|
previewImage: {
|
|
width: "100%",
|
|
aspectRatio: 1,
|
|
},
|
|
sheetBackground: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
handleIndicator: {
|
|
backgroundColor: theme.colors.palette.zinc[600],
|
|
},
|
|
sheetHeader: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
paddingHorizontal: theme.spacing[4],
|
|
paddingVertical: theme.spacing[3],
|
|
borderBottomWidth: theme.borderWidth[1],
|
|
borderBottomColor: theme.colors.border,
|
|
},
|
|
sheetTitle: {
|
|
fontSize: theme.fontSize.lg,
|
|
fontWeight: theme.fontWeight.semibold,
|
|
color: theme.colors.foreground,
|
|
flex: 1,
|
|
},
|
|
sheetCloseButton: {
|
|
padding: theme.spacing[2],
|
|
},
|
|
sheetCenterState: {
|
|
flex: 1,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: theme.spacing[2],
|
|
padding: theme.spacing[4],
|
|
},
|
|
}));
|