1214 lines
34 KiB
TypeScript
1214 lines
34 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
ActivityIndicator,
|
|
FlatList,
|
|
Image as RNImage,
|
|
LayoutChangeEvent,
|
|
ListRenderItemInfo,
|
|
Modal,
|
|
Pressable,
|
|
ScrollView as RNScrollView,
|
|
Text,
|
|
View,
|
|
Platform,
|
|
useWindowDimensions,
|
|
} from "react-native";
|
|
import { ScrollView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
|
import { Fonts } from "@/constants/theme";
|
|
import * as Clipboard from "expo-clipboard";
|
|
import {
|
|
BottomSheetModal,
|
|
BottomSheetScrollView,
|
|
BottomSheetBackdrop,
|
|
} from "@gorhom/bottom-sheet";
|
|
import {
|
|
File,
|
|
FileText,
|
|
Folder,
|
|
FolderOpen,
|
|
Image as ImageIcon,
|
|
MoreVertical,
|
|
X,
|
|
} from "lucide-react-native";
|
|
import type { ExplorerEntry, ExplorerFile } from "@/stores/session-store";
|
|
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
|
import { useSessionStore } from "@/stores/session-store";
|
|
import { useDownloadStore } from "@/stores/download-store";
|
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
|
import {
|
|
usePanelStore,
|
|
DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
|
|
type SortOption,
|
|
} from "@/stores/panel-store";
|
|
import { formatTimeAgo } from "@/utils/time";
|
|
|
|
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
|
{ value: "name", label: "Name" },
|
|
{ value: "modified", label: "Modified" },
|
|
{ value: "size", label: "Size" },
|
|
];
|
|
|
|
const INDENT_PER_LEVEL = 12;
|
|
|
|
interface FileExplorerPaneProps {
|
|
serverId: string;
|
|
agentId: string;
|
|
}
|
|
|
|
interface TreeRow {
|
|
entry: ExplorerEntry;
|
|
depth: number;
|
|
}
|
|
|
|
export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
|
const { theme } = useUnistyles();
|
|
const isMobile =
|
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
|
|
const { connectionStates } = useDaemonConnections();
|
|
const daemonProfile = connectionStates.get(serverId)?.daemon;
|
|
const agentExists = useSessionStore((state) =>
|
|
agentId && state.sessions[serverId]
|
|
? state.sessions[serverId]?.agents.has(agentId)
|
|
: false
|
|
);
|
|
const explorerState = useSessionStore((state) =>
|
|
agentId && state.sessions[serverId]
|
|
? state.sessions[serverId]?.fileExplorer.get(agentId)
|
|
: undefined
|
|
);
|
|
|
|
const {
|
|
requestDirectoryListing,
|
|
requestFilePreview,
|
|
requestFileDownloadToken,
|
|
selectExplorerEntry,
|
|
} = useFileExplorerActions(serverId);
|
|
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
|
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
|
const splitRatio = usePanelStore((state) => state.explorerFilesSplitRatio);
|
|
const setSplitRatio = usePanelStore((state) => state.setExplorerFilesSplitRatio);
|
|
|
|
const directories = explorerState?.directories ?? new Map();
|
|
const files = explorerState?.files ?? new Map();
|
|
const pendingRequest = explorerState?.pendingRequest ?? null;
|
|
const isExplorerLoading = explorerState?.isLoading ?? false;
|
|
const error = explorerState?.lastError ?? null;
|
|
const selectedEntryPath = explorerState?.selectedEntryPath ?? null;
|
|
|
|
const preview = selectedEntryPath ? files.get(selectedEntryPath) : null;
|
|
const isPreviewLoading = Boolean(
|
|
isExplorerLoading &&
|
|
pendingRequest?.mode === "file" &&
|
|
pendingRequest?.path === selectedEntryPath
|
|
);
|
|
|
|
const isDirectoryLoading = useCallback(
|
|
(path: string) =>
|
|
Boolean(
|
|
isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === path
|
|
),
|
|
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path]
|
|
);
|
|
|
|
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
|
|
const [menuEntry, setMenuEntry] = useState<ExplorerEntry | null>(null);
|
|
const [menuAnchor, setMenuAnchor] = useState({ top: 0, left: 0 });
|
|
const [menuHeight, setMenuHeight] = useState(0);
|
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
|
|
// Bottom sheet for file preview (mobile)
|
|
const previewSheetRef = useRef<BottomSheetModal>(null);
|
|
const previewSnapPoints = useMemo(() => ["70%", "95%"], []);
|
|
|
|
const hasInitializedRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!agentId || !requestDirectoryListing) {
|
|
return;
|
|
}
|
|
if (hasInitializedRef.current) {
|
|
return;
|
|
}
|
|
hasInitializedRef.current = true;
|
|
requestDirectoryListing(agentId, ".", { recordHistory: false, setCurrentPath: false });
|
|
}, [agentId, requestDirectoryListing]);
|
|
|
|
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
|
useEffect(() => {
|
|
if (!agentId || !selectedEntryPath || !requestDirectoryListing) {
|
|
return;
|
|
}
|
|
const parentDir = getParentDirectory(selectedEntryPath);
|
|
const ancestors = getAncestorDirectories(parentDir);
|
|
|
|
setExpandedPaths((prev) => {
|
|
const next = new Set(prev);
|
|
ancestors.forEach((path) => next.add(path));
|
|
return next;
|
|
});
|
|
|
|
ancestors.forEach((path) => {
|
|
if (!directories.has(path)) {
|
|
requestDirectoryListing(agentId, path, { recordHistory: false, setCurrentPath: false });
|
|
}
|
|
});
|
|
}, [agentId, directories, requestDirectoryListing, selectedEntryPath]);
|
|
|
|
// Open/close preview sheet based on selection
|
|
useEffect(() => {
|
|
if (!isMobile) {
|
|
return;
|
|
}
|
|
if (selectedEntryPath) {
|
|
previewSheetRef.current?.present();
|
|
} else {
|
|
previewSheetRef.current?.dismiss();
|
|
}
|
|
}, [isMobile, selectedEntryPath]);
|
|
|
|
const handleClosePreview = useCallback(() => {
|
|
if (!agentId) {
|
|
return;
|
|
}
|
|
selectExplorerEntry(agentId, null);
|
|
}, [agentId, selectExplorerEntry]);
|
|
|
|
const handleToggleDirectory = useCallback(
|
|
(entry: ExplorerEntry) => {
|
|
if (!agentId || !requestDirectoryListing) {
|
|
return;
|
|
}
|
|
|
|
const isExpanded = expandedPaths.has(entry.path);
|
|
const nextExpanded = !isExpanded;
|
|
setExpandedPaths((prev) => {
|
|
const next = new Set(prev);
|
|
if (isExpanded) {
|
|
next.delete(entry.path);
|
|
} else {
|
|
next.add(entry.path);
|
|
}
|
|
return next;
|
|
});
|
|
|
|
if (nextExpanded && !directories.has(entry.path)) {
|
|
requestDirectoryListing(agentId, entry.path, { recordHistory: false, setCurrentPath: false });
|
|
}
|
|
},
|
|
[agentId, directories, expandedPaths, requestDirectoryListing]
|
|
);
|
|
|
|
const handleOpenFile = useCallback(
|
|
(entry: ExplorerEntry) => {
|
|
if (!agentId || !requestFilePreview) {
|
|
return;
|
|
}
|
|
selectExplorerEntry(agentId, entry.path);
|
|
requestFilePreview(agentId, entry.path);
|
|
},
|
|
[agentId, requestFilePreview, 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(path);
|
|
}, []);
|
|
|
|
const handleOpenMenu = useCallback((entry: ExplorerEntry, event: any) => {
|
|
event.stopPropagation();
|
|
const { pageX, pageY } = event.nativeEvent ?? {};
|
|
setMenuAnchor({
|
|
left: typeof pageX === "number" ? pageX : 0,
|
|
top: typeof pageY === "number" ? pageY : 0,
|
|
});
|
|
setMenuEntry(entry);
|
|
}, []);
|
|
|
|
const handleCloseMenu = useCallback(() => {
|
|
setMenuEntry(null);
|
|
setMenuHeight(0);
|
|
}, []);
|
|
|
|
const handleMenuLayout = useCallback((event: LayoutChangeEvent) => {
|
|
const { height } = event.nativeEvent.layout;
|
|
setMenuHeight((current) => (current === height ? current : height));
|
|
}, []);
|
|
|
|
const startDownload = useDownloadStore((state) => state.startDownload);
|
|
const handleDownloadEntry = useCallback(
|
|
(entry: ExplorerEntry) => {
|
|
if (!agentId || !requestFileDownloadToken || entry.kind !== "file") {
|
|
return;
|
|
}
|
|
|
|
startDownload({
|
|
serverId,
|
|
agentId,
|
|
fileName: entry.name,
|
|
path: entry.path,
|
|
daemonProfile,
|
|
requestFileDownloadToken,
|
|
});
|
|
},
|
|
[agentId, serverId, daemonProfile, requestFileDownloadToken, startDownload]
|
|
);
|
|
|
|
const menuPosition = useMemo(() => {
|
|
if (!menuEntry) {
|
|
return null;
|
|
}
|
|
const menuWidth = 240;
|
|
const horizontalPadding = theme.spacing[2];
|
|
const verticalPadding = theme.spacing[2];
|
|
const maxLeft = Math.max(horizontalPadding, windowWidth - menuWidth - horizontalPadding);
|
|
const maxTop = Math.max(verticalPadding, windowHeight - menuHeight - verticalPadding);
|
|
const left = Math.min(
|
|
Math.max(menuAnchor.left - menuWidth + horizontalPadding, horizontalPadding),
|
|
maxLeft
|
|
);
|
|
const top = Math.min(Math.max(menuAnchor.top + verticalPadding, verticalPadding), maxTop);
|
|
return { top, left, width: menuWidth };
|
|
}, [menuAnchor.left, menuAnchor.top, menuEntry, menuHeight, theme.spacing, windowHeight, windowWidth]);
|
|
|
|
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 currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
|
|
|
|
const treeRows = useMemo(() => {
|
|
const rootDirectory = directories.get(".");
|
|
if (!rootDirectory) {
|
|
return [];
|
|
}
|
|
return buildTreeRows({
|
|
directories,
|
|
expandedPaths,
|
|
sortOption,
|
|
path: ".",
|
|
depth: 0,
|
|
});
|
|
}, [directories, expandedPaths, sortOption]);
|
|
|
|
const showInitialLoading =
|
|
!directories.has(".") &&
|
|
Boolean(isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === ".");
|
|
|
|
const shouldShowInlinePreview = !isMobile && Boolean(selectedEntryPath);
|
|
const dividerWidth = 10;
|
|
const minTreeWidth = 220;
|
|
const minPreviewWidth = 320;
|
|
|
|
const safeSplitRatio = Number.isFinite(splitRatio)
|
|
? splitRatio
|
|
: DEFAULT_EXPLORER_FILES_SPLIT_RATIO;
|
|
|
|
const treePaneWidth = useMemo(() => {
|
|
if (!shouldShowInlinePreview || containerWidth <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const available = Math.max(0, containerWidth - dividerWidth);
|
|
const maxTree = Math.max(minTreeWidth, available - minPreviewWidth);
|
|
const raw = Math.round(available * safeSplitRatio);
|
|
return Math.max(minTreeWidth, Math.min(maxTree, raw));
|
|
}, [containerWidth, safeSplitRatio, shouldShowInlinePreview]);
|
|
|
|
const resizeStartRef = useRef<{ startWidth: number; available: number } | null>(null);
|
|
|
|
const splitResizeGesture = useMemo(() => {
|
|
if (!shouldShowInlinePreview || containerWidth <= 0 || treePaneWidth === null) {
|
|
return Gesture.Pan().enabled(false);
|
|
}
|
|
|
|
const available = Math.max(0, containerWidth - dividerWidth);
|
|
|
|
return Gesture.Pan()
|
|
.enabled(!isMobile)
|
|
.runOnJS(true)
|
|
.activeOffsetX([-2, 2])
|
|
.failOffsetY([-5, 5])
|
|
.hitSlop({ left: 12, right: 12, top: 0, bottom: 0 })
|
|
.onBegin(() => {
|
|
resizeStartRef.current = {
|
|
startWidth: treePaneWidth,
|
|
available,
|
|
};
|
|
})
|
|
.onUpdate((event) => {
|
|
const start = resizeStartRef.current;
|
|
if (!start) {
|
|
return;
|
|
}
|
|
const nextWidth = start.startWidth + (event.translationX ?? 0);
|
|
const maxTree = Math.max(minTreeWidth, start.available - minPreviewWidth);
|
|
const clamped = Math.max(minTreeWidth, Math.min(maxTree, nextWidth));
|
|
const nextRatio = start.available > 0 ? clamped / start.available : safeSplitRatio;
|
|
setSplitRatio(nextRatio);
|
|
})
|
|
.onFinalize(() => {
|
|
resizeStartRef.current = null;
|
|
});
|
|
}, [
|
|
containerWidth,
|
|
dividerWidth,
|
|
isMobile,
|
|
setSplitRatio,
|
|
shouldShowInlinePreview,
|
|
safeSplitRatio,
|
|
treePaneWidth,
|
|
]);
|
|
|
|
const renderTreeRow = useCallback(
|
|
({ item }: ListRenderItemInfo<TreeRow>) => {
|
|
const entry = item.entry;
|
|
const depth = item.depth;
|
|
const displayKind = getEntryDisplayKind(entry);
|
|
const isDirectory = entry.kind === "directory";
|
|
const isExpanded = isDirectory && expandedPaths.has(entry.path);
|
|
const isSelected = selectedEntryPath === entry.path;
|
|
const loading = isDirectory && isDirectoryLoading(entry.path);
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={() => handleEntryPress(entry)}
|
|
style={({ hovered, pressed }) => [
|
|
styles.entryRow,
|
|
{ paddingLeft: theme.spacing[2] + depth * INDENT_PER_LEVEL },
|
|
(hovered || pressed || isSelected) && styles.entryRowActive,
|
|
]}
|
|
>
|
|
<View style={styles.entryInfo}>
|
|
<View style={styles.entryIcon}>
|
|
{loading ? (
|
|
<ActivityIndicator size="small" />
|
|
) : (
|
|
renderEntryIcon(isDirectory ? "directory" : displayKind, {
|
|
foreground: theme.colors.foregroundMuted,
|
|
primary: theme.colors.primary,
|
|
directoryOpen: isExpanded,
|
|
})
|
|
)}
|
|
</View>
|
|
<Text style={styles.entryName} numberOfLines={1}>
|
|
{entry.name}
|
|
</Text>
|
|
</View>
|
|
<Pressable
|
|
onPress={(event) => handleOpenMenu(entry, event)}
|
|
hitSlop={8}
|
|
style={({ hovered, pressed }) => [
|
|
styles.menuButton,
|
|
(hovered || pressed) && styles.menuButtonActive,
|
|
]}
|
|
>
|
|
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
|
</Pressable>
|
|
</Pressable>
|
|
);
|
|
},
|
|
[
|
|
expandedPaths,
|
|
handleEntryPress,
|
|
handleOpenMenu,
|
|
isDirectoryLoading,
|
|
selectedEntryPath,
|
|
theme.colors,
|
|
theme.spacing,
|
|
]
|
|
);
|
|
|
|
const listHeaderComponent = useMemo(() => {
|
|
return (
|
|
<View style={styles.headerContainer}>
|
|
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
|
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}, [currentSortLabel, handleSortCycle]);
|
|
|
|
const handlePreviewSheetChange = useCallback(
|
|
(index: number) => {
|
|
if (index === -1) {
|
|
handleClosePreview();
|
|
}
|
|
},
|
|
[handleClosePreview]
|
|
);
|
|
|
|
const renderPreviewBackdrop = useCallback(
|
|
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
|
<BottomSheetBackdrop
|
|
{...props}
|
|
disappearsOnIndex={-1}
|
|
appearsOnIndex={0}
|
|
opacity={0.5}
|
|
/>
|
|
),
|
|
[]
|
|
);
|
|
|
|
if (!agentExists) {
|
|
return (
|
|
<View style={styles.centerState}>
|
|
<Text style={styles.errorText}>Agent not found</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View
|
|
style={styles.container}
|
|
onLayout={(event) => setContainerWidth(event.nativeEvent.layout.width)}
|
|
>
|
|
{error ? (
|
|
<View style={styles.centerState}>
|
|
<Text style={styles.errorText}>{error}</Text>
|
|
<Pressable
|
|
style={styles.retryButton}
|
|
onPress={() => {
|
|
if (agentId) {
|
|
requestDirectoryListing(agentId, ".", {
|
|
recordHistory: false,
|
|
setCurrentPath: false,
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
<Text style={styles.retryButtonText}>Retry</Text>
|
|
</Pressable>
|
|
</View>
|
|
) : showInitialLoading ? (
|
|
<View style={styles.centerState}>
|
|
<ActivityIndicator size="small" />
|
|
<Text style={styles.loadingText}>Loading files…</Text>
|
|
</View>
|
|
) : treeRows.length === 0 ? (
|
|
<View style={styles.centerState}>
|
|
<Text style={styles.emptyText}>No files</Text>
|
|
</View>
|
|
) : (
|
|
<View style={styles.desktopSplit}>
|
|
<View
|
|
style={[
|
|
styles.treePane,
|
|
shouldShowInlinePreview && styles.treePaneWithPreview,
|
|
shouldShowInlinePreview && treePaneWidth !== null
|
|
? { width: treePaneWidth, flex: 0, flexGrow: 0, flexShrink: 0 }
|
|
: null,
|
|
]}
|
|
>
|
|
<FlatList
|
|
data={treeRows}
|
|
renderItem={renderTreeRow}
|
|
keyExtractor={(row) => row.entry.path}
|
|
contentContainerStyle={styles.entriesContent}
|
|
ListHeaderComponent={listHeaderComponent}
|
|
initialNumToRender={24}
|
|
maxToRenderPerBatch={40}
|
|
windowSize={12}
|
|
/>
|
|
</View>
|
|
|
|
{shouldShowInlinePreview ? (
|
|
<>
|
|
<GestureDetector gesture={splitResizeGesture}>
|
|
<View
|
|
pointerEvents="box-only"
|
|
style={[
|
|
styles.splitResizeHandle,
|
|
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
|
|
Platform.OS === "web" && ({ touchAction: "none", userSelect: "none" } as any),
|
|
]}
|
|
/>
|
|
</GestureDetector>
|
|
<View style={styles.previewPane}>
|
|
<View style={styles.previewHeaderContainer}>
|
|
<View style={styles.previewHeaderInner}>
|
|
<Pressable
|
|
onPress={() => {
|
|
if (selectedEntryPath) {
|
|
void Clipboard.setStringAsync(selectedEntryPath);
|
|
}
|
|
}}
|
|
style={({ hovered, pressed }) => [
|
|
styles.previewHeaderRow,
|
|
(hovered || pressed) && styles.previewHeaderRowHovered,
|
|
]}
|
|
>
|
|
<Text style={styles.previewHeaderText} numberOfLines={1}>
|
|
{selectedEntryPath?.split("/").pop() ?? "Preview"}
|
|
</Text>
|
|
{isPreviewLoading ? (
|
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
|
) : null}
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={handleClosePreview}
|
|
hitSlop={8}
|
|
style={({ hovered, pressed }) => [
|
|
styles.iconButton,
|
|
(hovered || pressed) && styles.previewHeaderRowHovered,
|
|
]}
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Close preview"
|
|
>
|
|
<X size={16} color={theme.colors.foregroundMuted} />
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
|
|
<FilePreviewBody preview={preview} isLoading={isPreviewLoading} variant="inline" />
|
|
</View>
|
|
</>
|
|
) : null}
|
|
</View>
|
|
)}
|
|
|
|
<Modal
|
|
visible={Boolean(menuEntry)}
|
|
animationType="fade"
|
|
transparent={true}
|
|
onRequestClose={handleCloseMenu}
|
|
>
|
|
<View style={styles.menuOverlay}>
|
|
<Pressable style={styles.menuBackdrop} onPress={handleCloseMenu} />
|
|
{menuEntry && menuPosition ? (
|
|
<View
|
|
style={[
|
|
styles.entryMenu,
|
|
{
|
|
position: "absolute",
|
|
top: menuPosition.top,
|
|
left: menuPosition.left,
|
|
width: menuPosition.width,
|
|
},
|
|
]}
|
|
onLayout={handleMenuLayout}
|
|
>
|
|
<View style={styles.entryMenuHeader}>
|
|
<Text style={styles.entryMenuMeta}>
|
|
{formatFileSize({ size: menuEntry.size })}
|
|
</Text>
|
|
<Text style={styles.entryMenuMeta}>
|
|
{formatTimeAgo(new Date(menuEntry.modifiedAt))}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.entryMenuDivider} />
|
|
<Pressable
|
|
style={({ hovered, pressed }) => [
|
|
styles.entryMenuItem,
|
|
(hovered || pressed) && styles.entryMenuItemHovered,
|
|
]}
|
|
onPress={() => {
|
|
handleCopyPath(menuEntry.path);
|
|
handleCloseMenu();
|
|
}}
|
|
>
|
|
<Text style={styles.entryMenuText}>Copy path</Text>
|
|
</Pressable>
|
|
{menuEntry.kind === "file" ? (
|
|
<Pressable
|
|
style={({ hovered, pressed }) => [
|
|
styles.entryMenuItem,
|
|
(hovered || pressed) && styles.entryMenuItemHovered,
|
|
]}
|
|
onPress={async () => {
|
|
handleCloseMenu();
|
|
handleDownloadEntry(menuEntry);
|
|
}}
|
|
>
|
|
<Text style={styles.entryMenuText}>Download</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
</Modal>
|
|
|
|
{isMobile ? (
|
|
<BottomSheetModal
|
|
ref={previewSheetRef}
|
|
snapPoints={previewSnapPoints}
|
|
index={0}
|
|
enableDynamicSizing={false}
|
|
onChange={handlePreviewSheetChange}
|
|
backdropComponent={renderPreviewBackdrop}
|
|
enablePanDownToClose
|
|
backgroundStyle={styles.sheetBackground}
|
|
handleIndicatorStyle={styles.handleIndicator}
|
|
>
|
|
<View style={styles.sheetHeader}>
|
|
<Text style={styles.sheetTitle} numberOfLines={1}>
|
|
{selectedEntryPath?.split("/").pop() ?? "Preview"}
|
|
</Text>
|
|
<Pressable onPress={handleClosePreview} style={styles.sheetCloseButton}>
|
|
<X size={20} color={theme.colors.foregroundMuted} />
|
|
</Pressable>
|
|
</View>
|
|
<FilePreviewBody preview={preview} isLoading={isPreviewLoading} variant="sheet" />
|
|
</BottomSheetModal>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function FilePreviewBody({
|
|
preview,
|
|
isLoading,
|
|
variant,
|
|
}: {
|
|
preview: ExplorerFile | null;
|
|
isLoading: boolean;
|
|
variant: "inline" | "sheet";
|
|
}) {
|
|
if (isLoading && !preview) {
|
|
return (
|
|
<View style={styles.sheetCenterState}>
|
|
<ActivityIndicator size="small" />
|
|
<Text style={styles.loadingText}>Loading file…</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (!preview) {
|
|
return (
|
|
<View style={styles.sheetCenterState}>
|
|
<Text style={styles.emptyText}>No preview available</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (preview.kind === "text") {
|
|
if (variant === "sheet") {
|
|
return (
|
|
<BottomSheetScrollView
|
|
style={styles.previewContent}
|
|
contentContainerStyle={styles.previewTextContent}
|
|
>
|
|
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
|
<Text style={styles.codeText}>{preview.content}</Text>
|
|
</ScrollView>
|
|
</BottomSheetScrollView>
|
|
);
|
|
}
|
|
return (
|
|
<RNScrollView
|
|
style={styles.previewContent}
|
|
contentContainerStyle={styles.previewTextContent}
|
|
>
|
|
<RNScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
|
<Text style={styles.codeText}>{preview.content}</Text>
|
|
</RNScrollView>
|
|
</RNScrollView>
|
|
);
|
|
}
|
|
|
|
if (preview.kind === "image" && preview.content) {
|
|
if (variant === "sheet") {
|
|
return (
|
|
<BottomSheetScrollView contentContainerStyle={styles.previewImageScrollContent}>
|
|
<RNImage
|
|
source={{
|
|
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
|
|
}}
|
|
style={styles.previewImage}
|
|
resizeMode="contain"
|
|
/>
|
|
</BottomSheetScrollView>
|
|
);
|
|
}
|
|
return (
|
|
<RNScrollView contentContainerStyle={styles.previewImageScrollContent}>
|
|
<RNImage
|
|
source={{
|
|
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
|
|
}}
|
|
style={styles.previewImage}
|
|
resizeMode="contain"
|
|
/>
|
|
</RNScrollView>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.sheetCenterState}>
|
|
<Text style={styles.emptyText}>Binary preview unavailable</Text>
|
|
<Text style={styles.entryMenuMeta}>{formatFileSize({ size: preview.size })}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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`;
|
|
}
|
|
|
|
type EntryDisplayKind = "directory" | "image" | "text" | "other";
|
|
|
|
const IMAGE_EXTENSIONS = new Set([
|
|
"png",
|
|
"jpg",
|
|
"jpeg",
|
|
"gif",
|
|
"bmp",
|
|
"svg",
|
|
"webp",
|
|
"ico",
|
|
]);
|
|
|
|
const TEXT_EXTENSIONS = new Set([
|
|
"txt",
|
|
"md",
|
|
"markdown",
|
|
"ts",
|
|
"tsx",
|
|
"js",
|
|
"jsx",
|
|
"json",
|
|
"yml",
|
|
"yaml",
|
|
"toml",
|
|
"py",
|
|
"rb",
|
|
"go",
|
|
"rs",
|
|
"java",
|
|
"kt",
|
|
"c",
|
|
"cpp",
|
|
"cc",
|
|
"h",
|
|
"hpp",
|
|
"cs",
|
|
"swift",
|
|
"php",
|
|
"html",
|
|
"css",
|
|
"scss",
|
|
"less",
|
|
"xml",
|
|
"sh",
|
|
"bash",
|
|
"zsh",
|
|
"ini",
|
|
"cfg",
|
|
"conf",
|
|
]);
|
|
|
|
function renderEntryIcon(
|
|
kind: EntryDisplayKind,
|
|
colors: { foreground: string; primary: string; directoryOpen?: boolean }
|
|
) {
|
|
const color = colors.foreground;
|
|
switch (kind) {
|
|
case "directory":
|
|
return colors.directoryOpen ? (
|
|
<FolderOpen size={18} color={colors.primary} />
|
|
) : (
|
|
<Folder size={18} color={colors.primary} />
|
|
);
|
|
case "image":
|
|
return <ImageIcon size={18} color={color} />;
|
|
case "text":
|
|
return <FileText size={18} color={color} />;
|
|
default:
|
|
return <File size={18} color={color} />;
|
|
}
|
|
}
|
|
|
|
function getEntryDisplayKind(entry: ExplorerEntry): EntryDisplayKind {
|
|
if (entry.kind === "directory") {
|
|
return "directory";
|
|
}
|
|
|
|
const extension = getExtension(entry.name);
|
|
if (extension === null) {
|
|
return "other";
|
|
}
|
|
|
|
if (IMAGE_EXTENSIONS.has(extension)) {
|
|
return "image";
|
|
}
|
|
|
|
if (TEXT_EXTENSIONS.has(extension)) {
|
|
return "text";
|
|
}
|
|
|
|
return "other";
|
|
}
|
|
|
|
function getExtension(name: string): string | null {
|
|
const index = name.lastIndexOf(".");
|
|
if (index === -1 || index === name.length - 1) {
|
|
return null;
|
|
}
|
|
return name.slice(index + 1).toLowerCase();
|
|
}
|
|
|
|
function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
|
|
const sorted = [...entries];
|
|
sorted.sort((a, b) => {
|
|
if (a.kind !== b.kind) {
|
|
return a.kind === "directory" ? -1 : 1;
|
|
}
|
|
switch (sortOption) {
|
|
case "name":
|
|
return a.name.localeCompare(b.name);
|
|
case "modified":
|
|
return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime();
|
|
case "size":
|
|
return b.size - a.size;
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
return sorted;
|
|
}
|
|
|
|
function buildTreeRows({
|
|
directories,
|
|
expandedPaths,
|
|
sortOption,
|
|
path,
|
|
depth,
|
|
}: {
|
|
directories: Map<string, { path: string; entries: ExplorerEntry[] }>;
|
|
expandedPaths: Set<string>;
|
|
sortOption: SortOption;
|
|
path: string;
|
|
depth: number;
|
|
}): TreeRow[] {
|
|
const directory = directories.get(path);
|
|
if (!directory) {
|
|
return [];
|
|
}
|
|
|
|
const rows: TreeRow[] = [];
|
|
const entries = sortEntries(directory.entries, sortOption);
|
|
|
|
for (const entry of entries) {
|
|
rows.push({ entry, depth });
|
|
if (entry.kind === "directory" && expandedPaths.has(entry.path)) {
|
|
rows.push(
|
|
...buildTreeRows({
|
|
directories,
|
|
expandedPaths,
|
|
sortOption,
|
|
path: entry.path,
|
|
depth: depth + 1,
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
function getParentDirectory(path: string): string {
|
|
const normalized = path.replace(/\/+$/, "");
|
|
if (!normalized || normalized === ".") {
|
|
return ".";
|
|
}
|
|
const lastSlash = normalized.lastIndexOf("/");
|
|
if (lastSlash === -1) {
|
|
return ".";
|
|
}
|
|
const dir = normalized.slice(0, lastSlash);
|
|
return dir.length > 0 ? dir : ".";
|
|
}
|
|
|
|
function getAncestorDirectories(directory: string): string[] {
|
|
const trimmed = directory.replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
if (!trimmed || trimmed === ".") {
|
|
return ["."];
|
|
}
|
|
|
|
const parts = trimmed.split("/").filter(Boolean);
|
|
const ancestors: string[] = ["."];
|
|
let acc = "";
|
|
for (const part of parts) {
|
|
acc = acc ? `${acc}/${part}` : part;
|
|
ancestors.push(acc);
|
|
}
|
|
return ancestors;
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: theme.colors.surface0,
|
|
},
|
|
desktopSplit: {
|
|
flex: 1,
|
|
flexDirection: "row",
|
|
minHeight: 0,
|
|
},
|
|
treePane: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
treePaneWithPreview: {
|
|
},
|
|
splitResizeHandle: {
|
|
width: 10,
|
|
backgroundColor: theme.colors.surface0,
|
|
borderLeftWidth: 1,
|
|
borderLeftColor: theme.colors.border,
|
|
borderRightWidth: 1,
|
|
borderRightColor: theme.colors.border,
|
|
},
|
|
previewPane: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
headerContainer: {
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingTop: theme.spacing[2],
|
|
paddingBottom: theme.spacing[1],
|
|
},
|
|
sortButton: {
|
|
alignSelf: "flex-end",
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
paddingVertical: theme.spacing[1],
|
|
paddingHorizontal: theme.spacing[2],
|
|
borderRadius: theme.borderRadius.md,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
},
|
|
sortButtonText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
},
|
|
entriesContent: {
|
|
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,
|
|
},
|
|
emptyText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.base,
|
|
textAlign: "center",
|
|
},
|
|
entryRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
paddingVertical: theme.spacing[1],
|
|
paddingRight: theme.spacing[2],
|
|
},
|
|
entryRowActive: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
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,
|
|
},
|
|
menuButton: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: theme.borderRadius.full,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
menuButtonActive: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
menuOverlay: {
|
|
flex: 1,
|
|
},
|
|
menuBackdrop: {
|
|
...StyleSheet.absoluteFillObject,
|
|
backgroundColor: "rgba(0, 0, 0, 0.2)",
|
|
},
|
|
entryMenu: {
|
|
borderRadius: theme.borderRadius.lg,
|
|
backgroundColor: theme.colors.surface1,
|
|
overflow: "hidden",
|
|
...(Platform.OS === "web"
|
|
? ({ boxShadow: "0 10px 30px rgba(0, 0, 0, 0.35)" } as any)
|
|
: {
|
|
shadowColor: "#000",
|
|
shadowOpacity: 0.35,
|
|
shadowRadius: 16,
|
|
shadowOffset: { width: 0, height: 10 },
|
|
elevation: 14,
|
|
}),
|
|
},
|
|
entryMenuHeader: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
paddingVertical: theme.spacing[3],
|
|
paddingHorizontal: theme.spacing[4],
|
|
},
|
|
entryMenuMeta: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
entryMenuDivider: {
|
|
height: 1,
|
|
backgroundColor: theme.colors.border,
|
|
},
|
|
entryMenuItem: {
|
|
paddingVertical: theme.spacing[4],
|
|
paddingHorizontal: theme.spacing[4],
|
|
},
|
|
entryMenuItemHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
entryMenuText: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.lg,
|
|
fontWeight: theme.fontWeight.semibold,
|
|
},
|
|
previewHeaderContainer: {
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: theme.colors.border,
|
|
},
|
|
previewHeaderInner: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingVertical: theme.spacing[2],
|
|
},
|
|
previewHeaderRow: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
borderRadius: theme.borderRadius.md,
|
|
paddingVertical: theme.spacing[1],
|
|
paddingHorizontal: theme.spacing[2],
|
|
},
|
|
previewHeaderRowHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
previewHeaderText: {
|
|
flex: 1,
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.semibold,
|
|
},
|
|
iconButton: {
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: theme.borderRadius.md,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
previewContent: {
|
|
flex: 1,
|
|
},
|
|
previewTextContent: {
|
|
padding: theme.spacing[3],
|
|
},
|
|
codeText: {
|
|
color: theme.colors.foreground,
|
|
fontFamily: Fonts.mono,
|
|
fontSize: theme.fontSize.sm,
|
|
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],
|
|
},
|
|
}));
|