Add read-only file explorer
This commit is contained in:
@@ -70,6 +70,7 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="audio-test" />
|
||||
<Stack.Screen name="git-diff" />
|
||||
<Stack.Screen name="file-explorer" />
|
||||
</Stack>
|
||||
<GlobalFooter />
|
||||
</AppContainer>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { MoreVertical, GitBranch } from "lucide-react-native";
|
||||
import { MoreVertical, GitBranch, Folder } from "lucide-react-native";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
@@ -185,6 +185,13 @@ export default function AgentScreen() {
|
||||
}
|
||||
}, [id, router, handleCloseMenu]);
|
||||
|
||||
const handleBrowseFiles = useCallback(() => {
|
||||
handleCloseMenu();
|
||||
if (id) {
|
||||
router.push(`/file-explorer?agentId=${id}`);
|
||||
}
|
||||
}, [handleCloseMenu, id, router]);
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -257,6 +264,10 @@ export default function AgentScreen() {
|
||||
<GitBranch size={20} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>View Changes</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={handleBrowseFiles} style={styles.menuItem}>
|
||||
<Folder size={20} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>Browse Files</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { useSession, type ExplorerEntry } from "@/contexts/session-context";
|
||||
|
||||
export default function FileExplorerScreen() {
|
||||
const { agentId } = useLocalSearchParams<{ agentId: string }>();
|
||||
const {
|
||||
agents,
|
||||
fileExplorer,
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
} = useSession();
|
||||
const [currentPath, setCurrentPath] = useState(".");
|
||||
const [selectedEntryPath, setSelectedEntryPath] = useState<string | null>(null);
|
||||
|
||||
const agent = agentId ? agents.get(agentId) : undefined;
|
||||
const explorerState = agentId ? fileExplorer.get(agentId) : undefined;
|
||||
const directory = explorerState?.directories.get(currentPath);
|
||||
const entries = directory?.entries ?? [];
|
||||
const isLoading = explorerState?.isLoading ?? false;
|
||||
const error = explorerState?.lastError ?? null;
|
||||
const preview = selectedEntryPath
|
||||
? explorerState?.files.get(selectedEntryPath)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
setCurrentPath(".");
|
||||
setSelectedEntryPath(null);
|
||||
requestDirectoryListing(agentId, ".");
|
||||
}, [agentId, requestDirectoryListing]);
|
||||
|
||||
const parentPath = useMemo(() => {
|
||||
if (currentPath === ".") {
|
||||
return null;
|
||||
}
|
||||
const segments = currentPath.split("/");
|
||||
segments.pop();
|
||||
const nextPath = segments.join("/");
|
||||
return nextPath.length === 0 ? "." : nextPath;
|
||||
}, [currentPath]);
|
||||
|
||||
const handleEntryPress = useCallback(
|
||||
(entry: ExplorerEntry) => {
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.kind === "directory") {
|
||||
setCurrentPath(entry.path);
|
||||
setSelectedEntryPath(null);
|
||||
requestDirectoryListing(agentId, entry.path);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedEntryPath(entry.path);
|
||||
requestFilePreview(agentId, entry.path);
|
||||
},
|
||||
[agentId, requestDirectoryListing, requestFilePreview]
|
||||
);
|
||||
|
||||
const handleNavigateUp = useCallback(() => {
|
||||
if (!agentId || !parentPath) {
|
||||
return;
|
||||
}
|
||||
setCurrentPath(parentPath);
|
||||
setSelectedEntryPath(null);
|
||||
requestDirectoryListing(agentId, parentPath);
|
||||
}, [agentId, parentPath, requestDirectoryListing]);
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Files" />
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>Agent not found</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Files" />
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.breadcrumbs}>
|
||||
<Text style={styles.breadcrumbLabel}>Path</Text>
|
||||
<ScrollView horizontal>
|
||||
<Text style={styles.breadcrumbText}>{currentPath}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
{parentPath && (
|
||||
<Pressable style={styles.upButton} onPress={handleNavigateUp}>
|
||||
<Text style={styles.upButtonText}>Up</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<View style={styles.listSection}>
|
||||
{error ? (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
) : isLoading && entries.length === 0 ? (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading...</Text>
|
||||
</View>
|
||||
) : entries.length === 0 ? (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>Directory is empty</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView>
|
||||
{entries.map((entry) => (
|
||||
<Pressable
|
||||
key={entry.path}
|
||||
style={[
|
||||
styles.entryRow,
|
||||
entry.kind === "directory"
|
||||
? styles.directoryRow
|
||||
: styles.fileRow,
|
||||
selectedEntryPath === entry.path && styles.selectedRow,
|
||||
]}
|
||||
onPress={() => handleEntryPress(entry)}
|
||||
>
|
||||
<View style={styles.entryTextContainer}>
|
||||
<Text style={styles.entryName}>{entry.name}</Text>
|
||||
<Text style={styles.entryMeta}>
|
||||
{entry.kind.toUpperCase()} ·{" "}
|
||||
{formatFileSize({ size: entry.size })} ·{" "}
|
||||
{formatModifiedTime({ value: entry.modifiedAt })}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.entryAction}>
|
||||
{entry.kind === "directory" ? "Open" : "Preview"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.previewSection}>
|
||||
{selectedEntryPath && isLoading && !preview ? (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading file...</Text>
|
||||
</View>
|
||||
) : !selectedEntryPath ? (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>Select a file to preview</Text>
|
||||
</View>
|
||||
) : !preview ? (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>No preview available yet</Text>
|
||||
</View>
|
||||
) : preview.kind === "text" ? (
|
||||
<ScrollView
|
||||
style={styles.textPreview}
|
||||
horizontal={false}
|
||||
contentContainerStyle={styles.textPreviewContent}
|
||||
>
|
||||
<ScrollView horizontal>
|
||||
<Text style={styles.codeText}>{preview.content}</Text>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
) : preview.kind === "image" && preview.content ? (
|
||||
<View style={styles.imagePreviewContainer}>
|
||||
<Image
|
||||
source={{
|
||||
uri: `data:${preview.mimeType ?? "image/png"};base64,${
|
||||
preview.content
|
||||
}`,
|
||||
}}
|
||||
style={styles.image}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>Binary preview unavailable</Text>
|
||||
<Text style={styles.entryMeta}>
|
||||
{formatFileSize({ size: preview.size })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</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`;
|
||||
}
|
||||
|
||||
function formatModifiedTime({ value }: { value: string }): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[2],
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
breadcrumbs: {
|
||||
flex: 1,
|
||||
},
|
||||
breadcrumbLabel: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
breadcrumbText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontFamily: "monospace",
|
||||
},
|
||||
upButton: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.muted,
|
||||
},
|
||||
upButtonText: {
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[4],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
listSection: {
|
||||
flex: 1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing[2],
|
||||
},
|
||||
previewSection: {
|
||||
flex: 1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing[2],
|
||||
},
|
||||
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",
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
},
|
||||
entryRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
directoryRow: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
},
|
||||
fileRow: {
|
||||
backgroundColor: theme.colors.card,
|
||||
},
|
||||
selectedRow: {
|
||||
borderWidth: theme.borderWidth[2],
|
||||
borderColor: theme.colors.primary,
|
||||
},
|
||||
entryTextContainer: {
|
||||
flex: 1,
|
||||
marginRight: theme.spacing[3],
|
||||
},
|
||||
entryName: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
entryMeta: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
entryAction: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
textPreview: {
|
||||
flex: 1,
|
||||
},
|
||||
textPreviewContent: {
|
||||
padding: theme.spacing[2],
|
||||
},
|
||||
codeText: {
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: "monospace",
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
imagePreviewContainer: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
image: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
}));
|
||||
@@ -81,6 +81,54 @@ export interface Command {
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
export type ExplorerEntryKind = "file" | "directory";
|
||||
export type ExplorerFileKind = "text" | "image" | "binary";
|
||||
export type ExplorerEncoding = "utf-8" | "base64" | "none";
|
||||
|
||||
export interface ExplorerEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
kind: ExplorerEntryKind;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
export interface ExplorerFile {
|
||||
path: string;
|
||||
kind: ExplorerFileKind;
|
||||
encoding: ExplorerEncoding;
|
||||
content?: string;
|
||||
mimeType?: string;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
interface ExplorerDirectory {
|
||||
path: string;
|
||||
entries: ExplorerEntry[];
|
||||
}
|
||||
|
||||
interface ExplorerRequestState {
|
||||
path: string;
|
||||
mode: "list" | "file";
|
||||
}
|
||||
|
||||
export interface AgentFileExplorerState {
|
||||
directories: Map<string, ExplorerDirectory>;
|
||||
files: Map<string, ExplorerFile>;
|
||||
isLoading: boolean;
|
||||
lastError: string | null;
|
||||
pendingRequest: ExplorerRequestState | null;
|
||||
}
|
||||
|
||||
const createExplorerState = (): AgentFileExplorerState => ({
|
||||
directories: new Map(),
|
||||
files: new Map(),
|
||||
isLoading: false,
|
||||
lastError: null,
|
||||
pendingRequest: null,
|
||||
});
|
||||
|
||||
|
||||
interface SessionContextValue {
|
||||
// WebSocket
|
||||
@@ -119,6 +167,11 @@ interface SessionContextValue {
|
||||
gitDiffs: Map<string, string>;
|
||||
requestGitDiff: (agentId: string) => void;
|
||||
|
||||
// File explorer
|
||||
fileExplorer: Map<string, AgentFileExplorerState>;
|
||||
requestDirectoryListing: (agentId: string, path: string) => void;
|
||||
requestFilePreview: (agentId: string, path: string) => void;
|
||||
|
||||
// Helpers
|
||||
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
|
||||
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
|
||||
@@ -172,6 +225,19 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
const [agentUpdates, setAgentUpdates] = useState<Map<string, AgentUpdate[]>>(new Map());
|
||||
const [pendingPermissions, setPendingPermissions] = useState<Map<string, PendingPermission>>(new Map());
|
||||
const [gitDiffs, setGitDiffs] = useState<Map<string, string>>(new Map());
|
||||
const [fileExplorer, setFileExplorer] = useState<Map<string, AgentFileExplorerState>>(new Map());
|
||||
|
||||
const updateExplorerState = useCallback(
|
||||
(agentId: string, updater: (state: AgentFileExplorerState) => AgentFileExplorerState) => {
|
||||
setFileExplorer((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(agentId) ?? createExplorerState();
|
||||
next.set(agentId, updater(current));
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// WebSocket message handlers
|
||||
useEffect(() => {
|
||||
@@ -613,6 +679,47 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
}
|
||||
});
|
||||
|
||||
const unsubFileExplorer = ws.on("file_explorer_response", (message) => {
|
||||
if (message.type !== "file_explorer_response") {
|
||||
return;
|
||||
}
|
||||
const { agentId, directory, file, mode, error } = message.payload;
|
||||
|
||||
console.log(
|
||||
"[Session] File explorer response for agent:",
|
||||
agentId,
|
||||
mode,
|
||||
error ? `(error: ${error})` : ""
|
||||
);
|
||||
|
||||
updateExplorerState(agentId, (state) => {
|
||||
const nextState: AgentFileExplorerState = {
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: error ?? null,
|
||||
pendingRequest: null,
|
||||
directories: state.directories,
|
||||
files: state.files,
|
||||
};
|
||||
|
||||
if (!error) {
|
||||
if (mode === "list" && directory) {
|
||||
const directories = new Map(state.directories);
|
||||
directories.set(directory.path, directory);
|
||||
nextState.directories = directories;
|
||||
}
|
||||
|
||||
if (mode === "file" && file) {
|
||||
const files = new Map(state.files);
|
||||
files.set(file.path, file);
|
||||
nextState.files = files;
|
||||
}
|
||||
}
|
||||
|
||||
return nextState;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubSessionState();
|
||||
unsubAgentCreated();
|
||||
@@ -626,8 +733,9 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
unsubChunk();
|
||||
unsubTranscription();
|
||||
unsubGitDiff();
|
||||
unsubFileExplorer();
|
||||
};
|
||||
}, [ws, audioPlayer, setIsPlayingAudio]);
|
||||
}, [ws, audioPlayer, setIsPlayingAudio, updateExplorerState]);
|
||||
|
||||
const initializeAgent = useCallback(({ agentId, requestId }: { agentId: string; requestId?: string }) => {
|
||||
setInitializingAgents((prev) => {
|
||||
@@ -808,6 +916,48 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
ws.send(msg);
|
||||
}, [ws]);
|
||||
|
||||
const requestDirectoryListing = useCallback((agentId: string, path: string) => {
|
||||
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||
updateExplorerState(agentId, (state) => ({
|
||||
...state,
|
||||
isLoading: true,
|
||||
lastError: null,
|
||||
pendingRequest: { path: normalizedPath, mode: "list" },
|
||||
}));
|
||||
|
||||
const msg: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "file_explorer_request",
|
||||
agentId,
|
||||
path: normalizedPath,
|
||||
mode: "list",
|
||||
},
|
||||
};
|
||||
ws.send(msg);
|
||||
}, [updateExplorerState, ws]);
|
||||
|
||||
const requestFilePreview = useCallback((agentId: string, path: string) => {
|
||||
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||
updateExplorerState(agentId, (state) => ({
|
||||
...state,
|
||||
isLoading: true,
|
||||
lastError: null,
|
||||
pendingRequest: { path: normalizedPath, mode: "file" },
|
||||
}));
|
||||
|
||||
const msg: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "file_explorer_request",
|
||||
agentId,
|
||||
path: normalizedPath,
|
||||
mode: "file",
|
||||
},
|
||||
};
|
||||
ws.send(msg);
|
||||
}, [updateExplorerState, ws]);
|
||||
|
||||
const value: SessionContextValue = {
|
||||
ws,
|
||||
audioPlayer,
|
||||
@@ -831,6 +981,9 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
setPendingPermissions,
|
||||
gitDiffs,
|
||||
requestGitDiff,
|
||||
fileExplorer,
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
initializeAgent,
|
||||
sendAgentMessage,
|
||||
sendAgentAudio,
|
||||
|
||||
Reference in New Issue
Block a user