Files
paseo/packages/app/src/components/file-explorer-pane.tsx
T
Mohamed BoudraandClaude Opus 4.5 8c33df472f feat(explorer-sidebar): add right-side file explorer and git diff panel
- Add swipe-left gesture to open explorer sidebar on mobile
- Implement Changes (git diff) and Files tabs with toggle
- Add responsive gallery columns based on container width
- Handle hardware back button to close sidebar first
- Add gesture conflict resolution for horizontal scroll in diffs
- Add resize handle with cursor change on desktop
- Add sorting selector (name, modified, size) in file explorer

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 14:27:59 +07:00

1517 lines
44 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
FlatList,
Image as RNImage,
LayoutChangeEvent,
ListRenderItemInfo,
RefreshControl,
ViewToken,
NativeScrollEvent,
NativeSyntheticEvent,
Modal,
Platform,
Pressable,
ScrollView,
Text,
View,
useWindowDimensions,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import * as Clipboard from "expo-clipboard";
import { File as FSFile, Paths } from "expo-file-system";
import * as Sharing from "expo-sharing";
import {
BottomSheetModal,
BottomSheetScrollView,
BottomSheetBackdrop,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import {
Check,
ChevronDown,
Download,
File,
FileText,
Folder,
Image as ImageIcon,
MoreVertical,
X,
XCircle,
} from "lucide-react-native";
import type { ExplorerEntry } from "@/stores/session-store";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import type { DaemonProfile } from "@/contexts/daemon-registry-context";
import { useSessionStore } from "@/stores/session-store";
import {
useExplorerSidebarStore,
type SortOption,
} from "@/stores/explorer-sidebar-store";
const MAX_CONCURRENT_THUMBNAILS = 2;
const THUMBNAIL_TIMEOUT_MS = 15000;
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "name", label: "Name" },
{ value: "modified", label: "Modified" },
{ value: "size", label: "Size" },
];
interface FileExplorerPaneProps {
serverId: string;
agentId: string;
}
export function FileExplorerPane({
serverId,
agentId,
}: FileExplorerPaneProps) {
const { theme } = useUnistyles();
const { connectionStates } = useDaemonConnections();
const daemonProfile = connectionStates.get(serverId)?.daemon;
const agent = useSessionStore((state) =>
agentId && state.sessions[serverId]
? state.sessions[serverId]?.agents.get(agentId)
: undefined
);
const explorerState = useSessionStore((state) =>
agentId && state.sessions[serverId]
? state.sessions[serverId]?.fileExplorer.get(agentId)
: undefined
);
const methods = useSessionStore((state) => state.sessions[serverId]?.methods);
const requestDirectoryListing = methods?.requestDirectoryListing;
const requestFilePreview = methods?.requestFilePreview;
const requestFileDownloadToken = methods?.requestFileDownloadToken;
const navigateExplorerBack = methods?.navigateExplorerBack;
const { viewMode, sortOption, setSortOption } = useExplorerSidebarStore();
const [selectedEntryPath, setSelectedEntryPath] = useState<string | null>(null);
const listScrollRef = useRef<FlatList<ExplorerEntry> | null>(null);
const listScrollOffsetRef = useRef(0);
const scrollOffsetsByPathRef = useRef<Map<string, number>>(new Map());
const pendingScrollRestoreRef = useRef<number | null>(null);
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const history = explorerState?.history ?? [];
const lastKnownDirectory = history[history.length - 1];
const rememberedDirectory = explorerState?.lastVisitedPath;
const initialTargetDirectory = rememberedDirectory ?? lastKnownDirectory ?? ".";
const currentPath = explorerState?.currentPath ?? ".";
const pendingRequest = explorerState?.pendingRequest ?? null;
const isExplorerLoading = explorerState?.isLoading ?? false;
const isListingLoading = Boolean(
isExplorerLoading && pendingRequest?.mode === "list"
);
const pendingDirectoryPath =
isListingLoading && pendingRequest ? pendingRequest.path : null;
const activePath = pendingDirectoryPath ?? currentPath;
const directory = explorerState?.directories.get(activePath);
const rawEntries = directory?.entries ?? [];
const entries = useMemo(() => {
const sorted = [...rawEntries];
sorted.sort((a, b) => {
// Directories always come first
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;
}, [rawEntries, sortOption]);
const showInitialListLoading = isListingLoading && entries.length === 0;
const showListLoadingBanner = isListingLoading && entries.length > 0;
const isPreviewLoading = Boolean(
isExplorerLoading &&
pendingRequest?.mode === "file" &&
pendingRequest?.path === selectedEntryPath
);
const error = explorerState?.lastError ?? null;
const preview = selectedEntryPath
? explorerState?.files.get(selectedEntryPath)
: null;
const shouldShowPreview = Boolean(selectedEntryPath);
const [thumbnailLoadingMap, setThumbnailLoadingMap] = useState<Record<string, boolean>>({});
const viewabilityConfigRef = useRef({
itemVisiblePercentThreshold: 10,
minimumViewTime: 0,
});
// Bottom sheet for file preview
const previewSheetRef = useRef<BottomSheetModal>(null);
const previewSnapPoints = useMemo(() => ["70%", "95%"], []);
// Thumbnail queue state
const thumbnailQueueRef = useRef<string[]>([]);
const inFlightPathsRef = useRef<Set<string>>(new Set());
// Responsive gallery columns based on container width
const [containerWidth, setContainerWidth] = useState(0);
const gridColumnCount = containerWidth > 0 && containerWidth >= 400 ? 4 : 3;
const listColumns = viewMode === "grid" ? gridColumnCount : 1;
const listKey = viewMode === "grid" ? `grid-${gridColumnCount}` : "list";
const [menuEntry, setMenuEntry] = useState<ExplorerEntry | null>(null);
const [menuAnchor, setMenuAnchor] = useState({ top: 0, left: 0 });
const [menuHeight, setMenuHeight] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false);
const [downloadToast, setDownloadToast] = useState<{
status: "downloading" | "complete" | "error";
fileName: string;
message?: string;
} | null>(null);
const downloadToastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const agentIdRef = useRef(agentId);
const viewModeRef = useRef(viewMode);
const requestFilePreviewRef = useRef(requestFilePreview);
const explorerFilesRef = useRef(explorerState?.files);
const refreshPathRef = useRef<string | null>(null);
const refreshStartedRef = useRef(false);
const hasInitializedRef = useRef(false);
useEffect(() => {
agentIdRef.current = agentId;
}, [agentId]);
useEffect(() => {
viewModeRef.current = viewMode;
}, [viewMode]);
useEffect(() => {
requestFilePreviewRef.current = requestFilePreview;
}, [requestFilePreview]);
useEffect(() => {
explorerFilesRef.current = explorerState?.files;
}, [explorerState?.files]);
// Process items from the thumbnail queue
const processNextThumbnail = useCallback(() => {
const currentAgentId = agentIdRef.current;
const currentRequestFilePreview = requestFilePreviewRef.current;
if (!currentAgentId || !currentRequestFilePreview) {
return;
}
while (
inFlightPathsRef.current.size < MAX_CONCURRENT_THUMBNAILS &&
thumbnailQueueRef.current.length > 0
) {
const path = thumbnailQueueRef.current.shift()!;
if (explorerFilesRef.current?.has(path) || inFlightPathsRef.current.has(path)) {
continue;
}
inFlightPathsRef.current.add(path);
setThumbnailLoadingMap((prev) => ({ ...prev, [path]: true }));
currentRequestFilePreview(currentAgentId, path);
setTimeout(() => {
if (inFlightPathsRef.current.has(path)) {
inFlightPathsRef.current.delete(path);
setThumbnailLoadingMap((prev) => {
const next = { ...prev };
delete next[path];
return next;
});
processNextThumbnail();
}
}, THUMBNAIL_TIMEOUT_MS);
}
}, []);
// Enqueue a file preview request
const enqueueFilePreview = useCallback(
(path: string, options?: { priority?: boolean }) => {
const currentAgentId = agentIdRef.current;
const currentRequestFilePreview = requestFilePreviewRef.current;
if (!currentAgentId || !currentRequestFilePreview) {
return;
}
if (explorerFilesRef.current?.has(path)) {
return;
}
if (options?.priority) {
thumbnailQueueRef.current = [];
if (inFlightPathsRef.current.has(path)) {
return;
}
if (inFlightPathsRef.current.size > 0) {
const abandonedPaths = Array.from(inFlightPathsRef.current);
setThumbnailLoadingMap((prev) => {
const next = { ...prev };
abandonedPaths.forEach((p) => delete next[p]);
return next;
});
inFlightPathsRef.current.clear();
}
inFlightPathsRef.current.add(path);
setThumbnailLoadingMap((prev) => ({ ...prev, [path]: true }));
currentRequestFilePreview(currentAgentId, path);
setTimeout(() => {
if (inFlightPathsRef.current.has(path)) {
inFlightPathsRef.current.delete(path);
setThumbnailLoadingMap((prev) => {
const next = { ...prev };
delete next[path];
return next;
});
processNextThumbnail();
}
}, THUMBNAIL_TIMEOUT_MS);
return;
}
if (
!thumbnailQueueRef.current.includes(path) &&
!inFlightPathsRef.current.has(path)
) {
thumbnailQueueRef.current.push(path);
processNextThumbnail();
}
},
[processNextThumbnail]
);
const handleViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
const currentViewMode = viewModeRef.current;
if (currentViewMode !== "grid") {
return;
}
viewableItems.forEach((token) => {
const item = token.item as ExplorerEntry | undefined;
if (!item || getEntryDisplayKind(item) !== "image") {
return;
}
enqueueFilePreviewRef.current?.(item.path);
});
}
);
const enqueueFilePreviewRef = useRef(enqueueFilePreview);
useEffect(() => {
enqueueFilePreviewRef.current = enqueueFilePreview;
}, [enqueueFilePreview]);
const restoreQueuedScrollOffset = useCallback(() => {
if (pendingScrollRestoreRef.current === null) {
return;
}
if (!listScrollRef.current) {
return;
}
const targetOffset = pendingScrollRestoreRef.current;
listScrollRef.current.scrollToOffset({ offset: targetOffset, animated: false });
listScrollOffsetRef.current = targetOffset;
pendingScrollRestoreRef.current = null;
}, []);
const queueScrollRestore = useCallback((offset: number) => {
pendingScrollRestoreRef.current = offset;
requestAnimationFrame(restoreQueuedScrollOffset);
}, [restoreQueuedScrollOffset]);
useEffect(() => {
setSelectedEntryPath(null);
}, [activePath]);
// Open/close preview sheet based on selection
useEffect(() => {
if (selectedEntryPath) {
previewSheetRef.current?.present();
} else {
previewSheetRef.current?.dismiss();
}
}, [selectedEntryPath]);
useEffect(() => {
if (shouldShowPreview) {
return;
}
const savedOffset = scrollOffsetsByPathRef.current.get(activePath) ?? listScrollOffsetRef.current;
queueScrollRestore(savedOffset);
}, [activePath, queueScrollRestore, shouldShowPreview]);
useEffect(() => {
const savedOffset = scrollOffsetsByPathRef.current.get(activePath) ?? 0;
listScrollOffsetRef.current = savedOffset;
queueScrollRestore(savedOffset);
}, [activePath, queueScrollRestore]);
// Initial directory listing request
useEffect(() => {
if (!agentId || !requestDirectoryListing) {
return;
}
if (hasInitializedRef.current) {
return;
}
hasInitializedRef.current = true;
requestDirectoryListing(agentId, initialTargetDirectory);
}, [agentId, initialTargetDirectory, requestDirectoryListing]);
const handleEntryPress = useCallback(
(entry: ExplorerEntry) => {
if (!agentId || !requestDirectoryListing) {
return;
}
if (entry.kind === "directory") {
setSelectedEntryPath(null);
requestDirectoryListing(agentId, entry.path);
return;
}
setSelectedEntryPath(entry.path);
enqueueFilePreview(entry.path, { priority: true });
},
[agentId, requestDirectoryListing, enqueueFilePreview]
);
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 showDownloadToast = useCallback(
(toast: { status: "downloading" | "complete" | "error"; fileName: string; message?: string }) => {
if (downloadToastTimeoutRef.current) {
clearTimeout(downloadToastTimeoutRef.current);
downloadToastTimeoutRef.current = null;
}
setDownloadToast(toast);
if (toast.status !== "downloading") {
downloadToastTimeoutRef.current = setTimeout(() => {
setDownloadToast(null);
}, 3000);
}
},
[]
);
const handleDownloadEntry = useCallback(
async (entry: ExplorerEntry) => {
if (!agentId || !requestFileDownloadToken || entry.kind !== "file") {
return;
}
const displayName = entry.name;
try {
const tokenResponse = await requestFileDownloadToken(agentId, entry.path);
if (tokenResponse.error || !tokenResponse.token) {
throw new Error(tokenResponse.error ?? "Failed to request download token.");
}
const downloadTarget = resolveDaemonDownloadTarget(daemonProfile);
if (!downloadTarget.baseUrl) {
throw new Error("Download host is unavailable.");
}
const fileName = tokenResponse.fileName ?? entry.name;
const downloadUrl = buildDownloadUrl(
downloadTarget.baseUrl,
tokenResponse.token,
Platform.OS === "web" ? downloadTarget.authCredentials : null
);
if (Platform.OS === "web") {
triggerBrowserDownload(downloadUrl, fileName);
return;
}
showDownloadToast({ status: "downloading", fileName: displayName });
const targetFile = resolveDownloadTargetFile(fileName);
const downloadedFile = await FSFile.downloadFileAsync(
downloadUrl,
targetFile,
downloadTarget.authHeader
? { headers: { Authorization: downloadTarget.authHeader } }
: undefined
);
showDownloadToast({ status: "complete", fileName: displayName });
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(downloadedFile.uri, {
mimeType: tokenResponse.mimeType ?? undefined,
dialogTitle: fileName ? `Share ${fileName}` : "Share file",
});
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to download file.";
if (Platform.OS === "web") {
console.warn("[FileExplorer] Download failed:", message);
return;
}
showDownloadToast({ status: "error", fileName: displayName, message });
}
},
[agentId, daemonProfile, requestFileDownloadToken, showDownloadToast]
);
const menuPosition = useMemo(() => {
if (!menuEntry) {
return null;
}
const menuWidth = 180;
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 };
}, [menuEntry, menuAnchor.left, menuAnchor.top, menuHeight, theme.spacing, windowHeight, windowWidth]);
const handleListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offset = event.nativeEvent.contentOffset.y;
listScrollOffsetRef.current = offset;
scrollOffsetsByPathRef.current.set(activePath, offset);
},
[activePath]
);
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
const { width } = event.nativeEvent.layout;
setContainerWidth(width);
}, []);
const handleClosePreviewSheet = useCallback(() => {
setSelectedEntryPath(null);
}, []);
const handlePreviewSheetChange = useCallback((index: number) => {
if (index === -1) {
setSelectedEntryPath(null);
}
}, []);
const renderPreviewBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.5}
/>
),
[]
);
const handleRetryDirectory = useCallback(() => {
if (!agentId || !requestDirectoryListing) {
return;
}
requestDirectoryListing(agentId, activePath);
}, [agentId, requestDirectoryListing, activePath]);
const handleRefresh = useCallback(() => {
if (!agentId || !requestDirectoryListing) {
return;
}
refreshPathRef.current = activePath;
refreshStartedRef.current = false;
setIsRefreshing(true);
requestDirectoryListing(agentId, activePath, { recordHistory: false });
}, [agentId, requestDirectoryListing, activePath]);
useEffect(() => {
if (!isRefreshing) {
return;
}
const refreshPath = refreshPathRef.current;
if (!refreshPath) {
return;
}
const isMatchingList =
pendingRequest?.mode === "list" && pendingRequest?.path === refreshPath;
if (isMatchingList) {
refreshStartedRef.current = true;
return;
}
if (refreshStartedRef.current) {
setIsRefreshing(false);
refreshPathRef.current = null;
refreshStartedRef.current = false;
}
}, [isRefreshing, pendingRequest?.mode, pendingRequest?.path]);
const handleNavigateBack = useCallback(() => {
if (!agentId || !navigateExplorerBack) {
return;
}
if ((explorerState?.history?.length ?? 0) > 1) {
navigateExplorerBack(agentId);
}
}, [agentId, explorerState?.history?.length, navigateExplorerBack]);
const renderEntry = useCallback(
({ item }: ListRenderItemInfo<ExplorerEntry>) => {
if (viewMode === "grid") {
const preview = explorerState?.files.get(item.path);
const isImage = getEntryDisplayKind(item) === "image";
const isLoadingThumb = Boolean(thumbnailLoadingMap[item.path]);
return (
<Pressable
style={styles.gridCard}
onPress={() => handleEntryPress(item)}
>
<View
style={[styles.gridThumbnail, isImage && styles.gridImageBackground]}
>
{isImage && preview?.content ? (
<RNImage
source={{
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
}}
style={styles.gridImage}
resizeMode="cover"
/>
) : isImage && isLoadingThumb ? (
<ActivityIndicator size="small" />
) : (
renderEntryIcon(getEntryDisplayKind(item), theme.colors)
)}
</View>
<Text style={styles.gridName} numberOfLines={1}>
{item.name}
</Text>
<Text style={styles.gridMeta} numberOfLines={1}>
{formatFileSize({ size: item.size })}
</Text>
</Pressable>
);
}
const displayKind = getEntryDisplayKind(item);
return (
<Pressable
style={[styles.entryRow, styles.entryRowBackground]}
onPress={() => handleEntryPress(item)}
>
<View style={styles.entryInfo}>
<View style={styles.entryIcon}>
{renderEntryIcon(displayKind, theme.colors)}
</View>
<View style={styles.entryTextContainer}>
<Text style={styles.entryName} numberOfLines={1}>
{item.name}
</Text>
<Text style={styles.entryMeta} numberOfLines={1}>
{item.kind.toUpperCase()} · {formatFileSize({ size: item.size })} ·{" "}
{formatModifiedTime({ value: item.modifiedAt })}
</Text>
</View>
</View>
<Pressable
onPress={(event) => handleOpenMenu(item, event)}
hitSlop={8}
style={styles.menuButton}
>
<MoreVertical size={16} color={theme.colors.foreground} />
</Pressable>
</Pressable>
);
},
[
explorerState?.files,
handleEntryPress,
handleOpenMenu,
theme.colors,
thumbnailLoadingMap,
viewMode,
]
);
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 listHeaderComponent = useMemo(() => {
const canGoBack = (explorerState?.history?.length ?? 0) > 1;
return (
<View style={styles.headerContainer}>
<View style={styles.headerRow}>
<View style={styles.pathContainer}>
{canGoBack && (
<Pressable onPress={handleNavigateBack} style={styles.backButton}>
<Text style={styles.backButtonText}>←</Text>
</Pressable>
)}
<Text style={styles.pathText} numberOfLines={1}>
{formatDirectoryLabel(activePath)}
</Text>
</View>
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
<ChevronDown size={14} color={theme.colors.mutedForeground} />
</Pressable>
</View>
{showListLoadingBanner && (
<View style={styles.loadingBanner}>
<ActivityIndicator size="small" />
<Text style={styles.loadingBannerText}>
Loading {formatDirectoryLabel(activePath)}...
</Text>
</View>
)}
</View>
);
}, [activePath, currentSortLabel, explorerState?.history?.length, handleNavigateBack, handleSortCycle, showListLoadingBanner, theme.colors.mutedForeground]);
// Watch for completed file previews and process queue
useEffect(() => {
if (!explorerState) {
return;
}
const completedPaths: string[] = [];
for (const path of inFlightPathsRef.current) {
if (explorerState.files.has(path)) {
completedPaths.push(path);
}
}
if (completedPaths.length === 0) {
return;
}
for (const path of completedPaths) {
inFlightPathsRef.current.delete(path);
}
setThumbnailLoadingMap((prev) => {
const next = { ...prev };
for (const path of completedPaths) {
delete next[path];
}
return next;
});
queueMicrotask(() => {
processNextThumbnail();
});
}, [explorerState?.files.size, processNextThumbnail]);
// Clear queue and loading state on path/view change
useEffect(() => {
thumbnailQueueRef.current = [];
inFlightPathsRef.current.clear();
setThumbnailLoadingMap({});
}, [activePath, viewMode]);
if (!agent) {
return (
<View style={styles.centerState}>
<Text style={styles.errorText}>Agent not found</Text>
</View>
);
}
return (
<View style={styles.container} onLayout={handleContainerLayout}>
<View style={styles.content}>
<View style={styles.listSection}>
{error ? (
<View style={styles.centerState}>
<Text style={styles.errorText}>{error}</Text>
<Pressable style={styles.retryButton} onPress={handleRetryDirectory}>
<Text style={styles.retryButtonText}>Retry</Text>
</Pressable>
</View>
) : showInitialListLoading ? (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading directory...</Text>
</View>
) : entries.length === 0 ? (
<View style={styles.centerState}>
<Text style={styles.emptyText}>Directory is empty</Text>
</View>
) : (
<FlatList
ref={listScrollRef}
data={entries}
renderItem={renderEntry}
keyExtractor={(item) => item.path}
contentContainerStyle={
viewMode === "grid" ? styles.gridContent : styles.entriesContent
}
columnWrapperStyle={
viewMode === "grid" && listColumns > 1
? styles.gridColumnWrapper
: undefined
}
numColumns={listColumns}
key={listKey}
onScroll={handleListScroll}
scrollEventThrottle={16}
onLayout={restoreQueuedScrollOffset}
onContentSizeChange={restoreQueuedScrollOffset}
ListHeaderComponent={listHeaderComponent}
extraData={{ viewMode, thumbnailLoadingMap }}
initialNumToRender={20}
maxToRenderPerBatch={30}
windowSize={15}
refreshControl={
<RefreshControl refreshing={isRefreshing} onRefresh={handleRefresh} />
}
onViewableItemsChanged={handleViewableItemsChangedRef.current}
viewabilityConfig={viewabilityConfigRef.current}
/>
)}
</View>
</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}
>
<Pressable
style={styles.entryMenuItem}
onPress={() => {
handleCopyPath(menuEntry.path);
handleCloseMenu();
}}
>
<Text style={styles.entryMenuText}>Copy Path</Text>
</Pressable>
{menuEntry.kind === "file" ? (
<Pressable
style={styles.entryMenuItem}
onPress={async () => {
handleCloseMenu();
await handleDownloadEntry(menuEntry);
}}
>
<Text style={styles.entryMenuText}>Download</Text>
</Pressable>
) : null}
</View>
) : null}
</View>
</Modal>
<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={handleClosePreviewSheet} style={styles.sheetCloseButton}>
<X size={20} color={theme.colors.mutedForeground} />
</Pressable>
</View>
{isPreviewLoading && !preview ? (
<View style={styles.sheetCenterState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file...</Text>
</View>
) : !preview ? (
<View style={styles.sheetCenterState}>
<Text style={styles.emptyText}>No preview available yet</Text>
</View>
) : preview.kind === "text" ? (
<BottomSheetScrollView
style={styles.sheetContent}
contentContainerStyle={styles.sheetScrollContent}
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
<Text style={styles.codeText}>{preview.content}</Text>
</ScrollView>
</BottomSheetScrollView>
) : preview.kind === "image" && preview.content ? (
<BottomSheetScrollView
contentContainerStyle={styles.sheetImageScrollContent}
>
<RNImage
source={{
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
}}
style={styles.sheetImage}
resizeMode="contain"
/>
</BottomSheetScrollView>
) : (
<View style={styles.sheetCenterState}>
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.entryMeta}>
{formatFileSize({ size: preview.size })}
</Text>
</View>
)}
</BottomSheetModal>
{downloadToast && (
<View style={styles.downloadToast}>
<View style={styles.downloadToastContent}>
{downloadToast.status === "downloading" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : downloadToast.status === "complete" ? (
<Check size={18} color={theme.colors.primary} />
) : (
<XCircle size={18} color={theme.colors.destructive} />
)}
<View style={styles.downloadToastTextContainer}>
<Text style={styles.downloadToastFileName} numberOfLines={1}>
{downloadToast.fileName}
</Text>
<Text style={styles.downloadToastStatus}>
{downloadToast.status === "downloading"
? "Downloading..."
: downloadToast.status === "complete"
? "Download complete"
: downloadToast.message ?? "Download failed"}
</Text>
</View>
{downloadToast.status !== "downloading" && (
<Pressable
onPress={() => setDownloadToast(null)}
hitSlop={8}
style={styles.downloadToastDismiss}
>
<X size={16} color={theme.colors.mutedForeground} />
</Pressable>
)}
</View>
</View>
)}
</View>
);
}
function formatDirectoryLabel(path: string): string {
return path === "." ? "workspace root" : path;
}
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`;
}
function formatModifiedTime({ value }: { value: string }): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
}
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 }
) {
const color = colors.foreground;
switch (kind) {
case "directory":
return <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();
}
type DownloadTarget = {
baseUrl: string | null;
authHeader: string | null;
authCredentials: { username: string; password: string } | null;
};
function resolveDaemonDownloadTarget(daemon?: DaemonProfile): DownloadTarget {
const rawUrl = daemon?.restUrl ?? daemon?.wsUrl;
if (!rawUrl) {
return { baseUrl: null, authHeader: null, authCredentials: null };
}
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
return { baseUrl: null, authHeader: null, authCredentials: null };
}
if (parsed.protocol === "ws:") {
parsed.protocol = "http:";
} else if (parsed.protocol === "wss:") {
parsed.protocol = "https:";
}
let authCredentials: { username: string; password: string } | null = null;
if (parsed.username || parsed.password) {
authCredentials = {
username: decodeURIComponent(parsed.username),
password: decodeURIComponent(parsed.password),
};
parsed.username = "";
parsed.password = "";
}
parsed.pathname = parsed.pathname.replace(/\/ws\/?$/, "/");
const baseUrl = parsed.origin;
const authHeader = authCredentials
? `Basic ${btoa(`${authCredentials.username}:${authCredentials.password}`)}`
: null;
return { baseUrl, authHeader, authCredentials };
}
function buildDownloadUrl(
baseUrl: string,
token: string,
authCredentials: { username: string; password: string } | null
): string {
const url = new URL("/api/files/download", baseUrl);
url.searchParams.set("token", token);
if (authCredentials) {
url.username = authCredentials.username;
url.password = authCredentials.password;
}
return url.toString();
}
function triggerBrowserDownload(url: string, fileName: string) {
if (typeof document === "undefined") {
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener");
}
return;
}
const link = document.createElement("a");
link.href = url;
link.download = fileName;
link.rel = "noopener";
document.body.appendChild(link);
link.click();
link.remove();
}
function resolveDownloadTargetFile(fileName: string): FSFile {
const directory = Paths.cache ?? Paths.document;
if (!directory) {
throw new Error("No download directory available.");
}
const safeName = sanitizeDownloadFileName(fileName);
const split = splitFileName(safeName);
let targetFile = new FSFile(directory, safeName);
let suffix = 1;
while (targetFile.exists) {
targetFile = new FSFile(directory, `${split.base} (${suffix})${split.ext}`);
suffix += 1;
}
return targetFile;
}
function sanitizeDownloadFileName(fileName: string): string {
const trimmed = fileName.trim();
if (!trimmed) {
return "download";
}
return trimmed.replace(/[\\/:*?"<>|]+/g, "_");
}
function splitFileName(fileName: string): { base: string; ext: string } {
const lastDot = fileName.lastIndexOf(".");
if (lastDot <= 0) {
return { base: fileName, ext: "" };
}
return {
base: fileName.slice(0, lastDot),
ext: fileName.slice(lastDot),
};
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
content: {
flex: 1,
flexDirection: "column",
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[3],
gap: theme.spacing[3],
},
listSection: {
flex: 1,
},
entriesContent: {
paddingBottom: theme.spacing[4],
},
headerContainer: {
gap: theme.spacing[2],
paddingBottom: theme.spacing[2],
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
pathContainer: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginRight: theme.spacing[2],
},
pathText: {
flex: 1,
fontSize: theme.fontSize.sm,
color: theme.colors.mutedForeground,
fontFamily: "monospace",
},
backButton: {
padding: theme.spacing[1],
},
backButtonText: {
fontSize: theme.fontSize.lg,
color: theme.colors.foreground,
},
loadingBanner: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
loadingBannerText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
sortButton: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.card,
},
sortButtonText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
},
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
padding: theme.spacing[4],
},
loadingText: {
color: theme.colors.mutedForeground,
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.primary,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
},
retryButtonText: {
color: theme.colors.primary,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
emptyText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.base,
textAlign: "center",
},
entryRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
marginBottom: theme.spacing[1],
},
entryRowBackground: {
backgroundColor: theme.colors.card,
},
entryInfo: {
flex: 1,
flexDirection: "row",
alignItems: "center",
columnGap: theme.spacing[2],
marginRight: theme.spacing[3],
},
entryIcon: {
width: 28,
alignItems: "center",
},
entryTextContainer: {
flex: 1,
},
entryName: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
entryMeta: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
menuButton: {
width: 36,
height: 36,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
menuOverlay: {
flex: 1,
},
menuBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.2)",
},
entryMenu: {
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.card,
padding: theme.spacing[1],
},
entryMenuItem: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
entryMenuText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
codeText: {
color: theme.colors.foreground,
fontFamily: "monospace",
fontSize: theme.fontSize.sm,
flexShrink: 0,
},
gridContent: {
paddingBottom: theme.spacing[4],
paddingHorizontal: theme.spacing[1],
},
gridColumnWrapper: {
justifyContent: "space-between",
marginBottom: theme.spacing[2],
},
gridCard: {
flex: 1,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
padding: theme.spacing[2],
gap: theme.spacing[2],
backgroundColor: theme.colors.card,
marginHorizontal: theme.spacing[1],
marginBottom: theme.spacing[1],
minWidth: 0,
},
gridThumbnail: {
width: "100%",
aspectRatio: 1,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
backgroundColor: theme.colors.muted,
},
gridImageBackground: {
backgroundColor: theme.colors.background,
},
gridImage: {
width: "100%",
height: "100%",
},
gridName: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
gridMeta: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
},
sheetBackground: {
backgroundColor: theme.colors.card,
},
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],
},
sheetContent: {
flex: 1,
},
sheetScrollContent: {
padding: theme.spacing[4],
},
sheetCenterState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
padding: theme.spacing[4],
},
sheetImageScrollContent: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[4],
},
sheetImage: {
width: "100%",
aspectRatio: 1,
},
downloadToast: {
position: "absolute",
bottom: theme.spacing[4],
left: theme.spacing[4],
right: theme.spacing[4],
zIndex: 1000,
},
downloadToastContent: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 8,
},
downloadToastTextContainer: {
flex: 1,
gap: theme.spacing[1],
},
downloadToastFileName: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
downloadToastStatus: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
},
downloadToastDismiss: {
padding: theme.spacing[1],
},
}));