From e3113d6d4d54f0fbc2f633a40ba83e7c91966919 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 4 Mar 2026 17:50:23 -0800 Subject: [PATCH 1/5] feat(desktop): support parent directory navigation and external file paths in file browser - Add parent directory navigation button (arrow up) in file browser toolbar - Add breadcrumb bar showing current path when browsing outside worktree - Add home button to return to workspace root - Add filesystem.readFile endpoint for reading files by absolute path - Allow file viewer to open files outside the worktree via absolute paths - Remove toast warning when clicking external file links in terminal --- .../src/lib/trpc/routers/filesystem/index.ts | 54 ++++++ .../hooks/useFileContent/useFileContent.ts | 43 ++++- .../Terminal/hooks/useFileLinkClick.ts | 9 +- .../RightSidebar/FilesView/FilesView.tsx | 157 +++++++++++++----- .../FileTreeToolbar/FileTreeToolbar.tsx | 42 +++++ 5 files changed, 254 insertions(+), 51 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/filesystem/index.ts b/apps/desktop/src/lib/trpc/routers/filesystem/index.ts index 778ee3a1f6c..e552618701b 100644 --- a/apps/desktop/src/lib/trpc/routers/filesystem/index.ts +++ b/apps/desktop/src/lib/trpc/routers/filesystem/index.ts @@ -944,6 +944,60 @@ export const createFilesystemRouter = () => { return { copied, errors }; }), + /** + * Read a file by absolute path with size cap and binary detection. + * Used for viewing files outside the current worktree. + */ + readFile: publicProcedure + .input( + z.object({ + filePath: z.string(), + }), + ) + .query( + async ({ + input, + }): Promise< + | { + ok: true; + content: string; + truncated: boolean; + byteLength: number; + } + | { + ok: false; + reason: "not-found" | "too-large" | "binary"; + } + > => { + const MAX_FILE_SIZE = 2 * 1024 * 1024; + try { + const stats = await fs.stat(input.filePath); + if (stats.size > MAX_FILE_SIZE) { + return { ok: false, reason: "too-large" }; + } + + const buffer = await fs.readFile(input.filePath); + + // Binary detection (same as readWorkingFile) + const checkLength = Math.min(buffer.length, BINARY_CHECK_SIZE); + for (let i = 0; i < checkLength; i++) { + if (buffer[i] === 0) { + return { ok: false, reason: "binary" }; + } + } + + return { + ok: true, + content: buffer.toString("utf-8"), + truncated: false, + byteLength: buffer.length, + }; + } catch { + return { ok: false, reason: "not-found" }; + } + }, + ), + exists: publicProcedure .input(z.object({ path: z.string() })) .query(async ({ input }) => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts index 183ac11c9a7..93806be975c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts @@ -30,10 +30,17 @@ export function useFileContent({ const isRemote = filePath.startsWith("https://") || filePath.startsWith("http://"); + // Absolute paths (starting with /) are external files outside the worktree + const isAbsolutePath = filePath.startsWith("/"); + const { data: branchData } = electronTrpc.changes.getBranches.useQuery( { worktreePath }, { - enabled: !isRemote && !!worktreePath && diffCategory === "against-base", + enabled: + !isRemote && + !isAbsolutePath && + !!worktreePath && + diffCategory === "against-base", }, ); const effectiveBaseBranch = @@ -41,12 +48,28 @@ export function useFileContent({ const isImage = isImageFile(filePath); + // Use filesystem.readFile for absolute paths (external files) + const { data: externalFileData, isLoading: isLoadingExternal } = + electronTrpc.filesystem.readFile.useQuery( + { filePath }, + { + enabled: + isAbsolutePath && + !isRemote && + viewMode !== "diff" && + !isImage && + !!filePath, + }, + ); + + // Use changes.readWorkingFile for worktree-relative paths const { data: rawFileData, isLoading: isLoadingRaw } = electronTrpc.changes.readWorkingFile.useQuery( { worktreePath, filePath }, { enabled: !isRemote && + !isAbsolutePath && viewMode !== "diff" && !isImage && !!filePath && @@ -54,12 +77,19 @@ export function useFileContent({ }, ); + // Merge external and worktree file data into a single result + const effectiveRawFileData = isAbsolutePath ? externalFileData : rawFileData; + const effectiveIsLoadingRaw = isAbsolutePath + ? isLoadingExternal + : isLoadingRaw; + const { data: imageData, isLoading: isLoadingImage } = electronTrpc.changes.readWorkingFileImage.useQuery( { worktreePath, filePath }, { enabled: !isRemote && + !isAbsolutePath && viewMode === "rendered" && isImage && !!filePath && @@ -81,6 +111,7 @@ export function useFileContent({ { enabled: !isRemote && + !isAbsolutePath && viewMode === "diff" && !!diffCategory && !!filePath && @@ -90,10 +121,10 @@ export function useFileContent({ // biome-ignore lint/correctness/useExhaustiveDependencies: Only update baseline when content loads useEffect(() => { - if (rawFileData?.ok === true && !isDirty) { - originalContentRef.current = rawFileData.content; + if (effectiveRawFileData?.ok === true && !isDirty) { + originalContentRef.current = effectiveRawFileData.content; } - }, [rawFileData]); + }, [effectiveRawFileData]); // biome-ignore lint/correctness/useExhaustiveDependencies: Only update baseline when diff loads useEffect(() => { @@ -112,8 +143,8 @@ export function useFileContent({ ); return { - rawFileData, - isLoadingRaw: isLoadingRaw || (isImage && isLoadingImage), + rawFileData: effectiveRawFileData, + isLoadingRaw: effectiveIsLoadingRaw || (isImage && isLoadingImage), imageData: isRemote ? remoteImageData : imageData, isLoadingImage: isRemote ? false : isLoadingImage, diffData, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useFileLinkClick.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useFileLinkClick.ts index 9ef2948743f..5748438207e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useFileLinkClick.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useFileLinkClick.ts @@ -77,14 +77,9 @@ export function useFileLinkClick({ filePath = "."; } else if (path.startsWith(`${workspaceCwd}/`)) { filePath = path.slice(workspaceCwd.length + 1); - } else if (path.startsWith("/")) { - // Absolute path outside workspace - show warning and don't attempt to open - toast.warning("File is outside the workspace", { - description: - "Switch to 'External editor' in Settings to open this file", - }); - return; } + // Absolute paths outside workspace are passed through as-is. + // The file viewer supports loading external files via absolute paths. addFileViewerPane(workspaceId, { filePath, line, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx index cb231c36166..4a24d775cc8 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx @@ -11,9 +11,11 @@ import { ContextMenuItem, ContextMenuTrigger, } from "@superset/ui/context-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; import { useParams } from "@tanstack/react-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { LuFile, LuFolder } from "react-icons/lu"; +import { LuChevronRight, LuFile, LuFolder, LuHouse } from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useFileExplorerStore } from "renderer/stores/file-explorer"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -38,6 +40,11 @@ export function FilesView() { const worktreePath = workspace?.worktreePath; const [searchTerm, setSearchTerm] = useState(""); + // browseRoot allows navigating outside the worktree directory + const [browseRoot, setBrowseRoot] = useState(null); + const effectiveRoot = browseRoot ?? worktreePath; + const isOutsideWorktree = browseRoot !== null && browseRoot !== worktreePath; + const projectId = workspace?.project?.id; const showHiddenFiles = useFileExplorerStore((s) => projectId ? (s.showHiddenFiles[projectId] ?? false) : false, @@ -45,8 +52,8 @@ export function FilesView() { const toggleHiddenFiles = useFileExplorerStore((s) => s.toggleHiddenFiles); // Refs avoid stale closure in dataLoader callbacks - const worktreePathRef = useRef(worktreePath); - worktreePathRef.current = worktreePath; + const effectiveRootRef = useRef(effectiveRoot); + effectiveRootRef.current = effectiveRoot; const showHiddenFilesRef = useRef(showHiddenFiles); showHiddenFilesRef.current = showHiddenFiles; @@ -64,7 +71,7 @@ export function FilesView() { return { id: "root", name: "root", - path: worktreePathRef.current ?? "", + path: effectiveRootRef.current ?? "", relativePath: "", isDirectory: true, }; @@ -79,17 +86,17 @@ export function FilesView() { }; }, getChildren: async (itemId: string): Promise => { - const currentPath = worktreePathRef.current; - if (!currentPath) return []; + const currentRoot = effectiveRootRef.current; + if (!currentRoot) return []; const dirPath = - itemId === "root" ? currentPath : itemId.split(":::")[0]; + itemId === "root" ? currentRoot : itemId.split(":::")[0]; if (!dirPath) return []; try { const entries = await trpcUtils.filesystem.readDirectory.fetch({ dirPath, - rootPath: currentPath, + rootPath: currentRoot, includeHidden: showHiddenFilesRef.current, }); return entries.map( @@ -105,23 +112,40 @@ export function FilesView() { features: [asyncDataLoaderFeature, selectionFeature, expandAllFeature], }); - const prevWorktreePathRef = useRef(worktreePath); + const prevEffectiveRootRef = useRef(effectiveRoot); useEffect(() => { if ( - worktreePath && - prevWorktreePathRef.current !== worktreePath && - prevWorktreePathRef.current !== undefined + effectiveRoot && + prevEffectiveRootRef.current !== effectiveRoot && + prevEffectiveRootRef.current !== undefined ) { tree.getItemInstance("root")?.invalidateChildrenIds(); } - prevWorktreePathRef.current = worktreePath; - }, [worktreePath, tree]); + prevEffectiveRootRef.current = effectiveRoot; + }, [effectiveRoot, tree]); + + // Reset browseRoot when switching workspaces + // biome-ignore lint/correctness/useExhaustiveDependencies: Reset on worktree change only + useEffect(() => { + setBrowseRoot(null); + }, [worktreePath]); + + const navigateToParent = useCallback(() => { + const current = effectiveRoot; + if (!current || current === "/") return; + const parent = current.replace(/\/[^/]+\/?$/, "") || "/"; + setBrowseRoot(parent); + }, [effectiveRoot]); + + const navigateHome = useCallback(() => { + setBrowseRoot(null); + }, []); const { createFile, createDirectory, rename, deleteItems, isDeleting } = useFileTreeActions({ worktreePath, onRefresh: async (parentPath: string) => { - const isRoot = parentPath === worktreePath; + const isRoot = parentPath === effectiveRoot; const itemId = isRoot ? "root" : tree @@ -142,7 +166,7 @@ export function FilesView() { isFetching: isSearchFetching, hasQuery: isSearching, } = useFileSearch({ - worktreePath, + worktreePath: effectiveRoot, searchTerm, includeHidden: showHiddenFiles, }); @@ -160,11 +184,14 @@ export function FilesView() { const handleFileActivate = useCallback( (entry: DirectoryEntry) => { if (!workspaceId || !worktreePath || entry.isDirectory) return; - addFileViewerPane(workspaceId, { - filePath: entry.relativePath, - }); + // When browsing outside the worktree, use the absolute path + const filePath = + isOutsideWorktree || entry.relativePath.startsWith("..") + ? entry.path + : entry.relativePath; + addFileViewerPane(workspaceId, { filePath }); }, - [workspaceId, worktreePath, addFileViewerPane], + [workspaceId, worktreePath, isOutsideWorktree, addFileViewerPane], ); const handleOpenInEditor = useCallback( @@ -181,7 +208,7 @@ export function FilesView() { const handleNewFile = useCallback( async (parentPath: string) => { - if (parentPath !== worktreePath) { + if (parentPath !== effectiveRoot) { const item = tree .getItems() .find( @@ -195,12 +222,12 @@ export function FilesView() { setNewItemMode("file"); setNewItemParentPath(parentPath); }, - [worktreePath, tree], + [effectiveRoot, tree], ); const handleNewFolder = useCallback( async (parentPath: string) => { - if (parentPath !== worktreePath) { + if (parentPath !== effectiveRoot) { const item = tree .getItems() .find( @@ -214,7 +241,7 @@ export function FilesView() { setNewItemMode("folder"); setNewItemParentPath(parentPath); }, - [worktreePath, tree], + [effectiveRoot, tree], ); const handleNewItemSubmit = useCallback( @@ -305,6 +332,16 @@ export function FilesView() { })); }, [searchResults]); + // Build breadcrumb segments for the current browse root + const breadcrumbSegments = useMemo(() => { + if (!isOutsideWorktree || !effectiveRoot) return null; + const parts = effectiveRoot.split("/").filter(Boolean); + return parts.map((name, i) => ({ + name, + path: `/${parts.slice(0, i + 1).join("/")}`, + })); + }, [isOutsideWorktree, effectiveRoot]); + if (!worktreePath) { return (
@@ -318,26 +355,66 @@ export function FilesView() { handleNewFile(worktreePath)} - onNewFolder={() => handleNewFolder(worktreePath)} + onNewFile={() => handleNewFile(effectiveRoot ?? worktreePath)} + onNewFolder={() => handleNewFolder(effectiveRoot ?? worktreePath)} onCollapseAll={handleCollapseAll} onRefresh={handleRefresh} showHiddenFiles={showHiddenFiles} onToggleHiddenFiles={handleToggleHiddenFiles} + onNavigateToParent={navigateToParent} + onNavigateHome={isOutsideWorktree ? navigateHome : undefined} /> + {breadcrumbSegments && ( +
+ + + + + + Back to {worktreePath?.split("/").pop()} + + + + {breadcrumbSegments.map((segment, i) => ( + + {i > 0 && ( + + )} + + + ))} +
+ )} +
- {newItemMode && newItemParentPath === worktreePath && ( - - )} + {newItemMode && + newItemParentPath === (effectiveRoot ?? worktreePath) && ( + + )} {isSearching ? ( searchResultEntries.length > 0 ? ( @@ -354,7 +431,7 @@ export function FilesView() { - handleNewFile(worktreePath)}> + handleNewFile(effectiveRoot ?? worktreePath)} + > New File - handleNewFolder(worktreePath)}> + handleNewFolder(effectiveRoot ?? worktreePath)} + > New Folder diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeToolbar/FileTreeToolbar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeToolbar/FileTreeToolbar.tsx index 7d70677ff5c..d6d801ebc28 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeToolbar/FileTreeToolbar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeToolbar/FileTreeToolbar.tsx @@ -3,11 +3,13 @@ import { Input } from "@superset/ui/input"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useCallback, useEffect, useRef, useState } from "react"; import { + LuArrowUp, LuChevronsDownUp, LuEye, LuEyeOff, LuFilePlus, LuFolderPlus, + LuHouse, LuRefreshCw, LuX, } from "react-icons/lu"; @@ -23,6 +25,8 @@ interface FileTreeToolbarProps { showHiddenFiles: boolean; onToggleHiddenFiles: () => void; isRefreshing?: boolean; + onNavigateToParent?: () => void; + onNavigateHome?: (() => void) | undefined; } export function FileTreeToolbar({ @@ -35,6 +39,8 @@ export function FileTreeToolbar({ showHiddenFiles, onToggleHiddenFiles, isRefreshing = false, + onNavigateToParent, + onNavigateHome, }: FileTreeToolbarProps) { const [localSearchTerm, setLocalSearchTerm] = useState(searchTerm); const debounceTimeoutRef = useRef | null>(null); @@ -103,6 +109,42 @@ export function FileTreeToolbar({
+ {onNavigateToParent && ( + + + + + + Go to Parent Directory + + + )} + + {onNavigateHome && ( + + + + + + Back to Workspace Root + + + )} +