From a3dfd4b5412cd5902fa03402ef1c801fe002111c Mon Sep 17 00:00:00 2001 From: Dan Cieslak Date: Fri, 5 Jun 2026 13:44:44 -0500 Subject: [PATCH] feat: add Files tab for inline project file browsing (#3443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a feature-gated Files tab to the right sidebar that lets users browse and read project files (markdown, code, HTML) inline without leaving mux. - New FILE_BROWSER experiment flag (disabled by default, user-toggleable) - listProjectFiles + readProjectFile IPC endpoints with path-traversal protection (path.relative guard) and binary file detection (null-byte scan) - Files tab registered in tabConfig/tabRegistry with featureFlag gate, mirroring the existing Agent Browser and Portable Desktop pattern - FilesTab component: collapsible file tree (dirs-first) + content viewer - Markdown rendered via MarkdownRenderer, code via HighlightedCode (Shiki) - HTML shown as syntax-highlighted source, never live-rendered (XSS prevention) - Breadcrumb navigation via pathDirname walk — Windows-safe, no separator assumptions - Stale request cancellation: AbortController for dir listing, load-ID counter for file reads Closes #3443 Co-Authored-By: Claude Sonnet 4.6 --- .../RightSidebar/FilesTab/FilesTab.tsx | 400 ++++++++++++++++++ .../features/RightSidebar/RightSidebar.tsx | 27 ++ .../features/RightSidebar/Tabs/TabLabels.tsx | 9 + .../features/RightSidebar/Tabs/tabConfig.ts | 7 + .../RightSidebar/Tabs/tabRegistry.tsx | 10 + src/common/constants/experiments.ts | 10 + src/common/orpc/schemas/api.ts | 22 + src/node/orpc/router.ts | 12 + src/node/services/projectService.ts | 95 +++++ 9 files changed, 592 insertions(+) create mode 100644 src/browser/features/RightSidebar/FilesTab/FilesTab.tsx diff --git a/src/browser/features/RightSidebar/FilesTab/FilesTab.tsx b/src/browser/features/RightSidebar/FilesTab/FilesTab.tsx new file mode 100644 index 0000000000..166dca6bb7 --- /dev/null +++ b/src/browser/features/RightSidebar/FilesTab/FilesTab.tsx @@ -0,0 +1,400 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { ChevronRight, File, Folder, FolderOpen } from "lucide-react"; +import { useAPI } from "@/browser/contexts/API"; +import { MarkdownRenderer } from "@/browser/features/Messages/MarkdownRenderer"; +import { HighlightedCode } from "@/browser/features/Tools/Shared/HighlightedCode"; +import { cn } from "@/common/lib/utils"; + +interface FilesTabProps { + projectPath: string; +} + +interface FileEntry { + name: string; + path: string; + isDirectory: boolean; +} + +// Maps common file extensions to Shiki language identifiers. +const EXTENSION_TO_LANGUAGE: Record = { + ts: "typescript", + tsx: "tsx", + js: "javascript", + jsx: "jsx", + mjs: "javascript", + cjs: "javascript", + py: "python", + rb: "ruby", + go: "go", + rs: "rust", + cpp: "cpp", + cc: "cpp", + cxx: "cpp", + c: "c", + h: "c", + hpp: "cpp", + java: "java", + cs: "csharp", + php: "php", + swift: "swift", + kt: "kotlin", + css: "css", + scss: "scss", + less: "less", + html: "html", + htm: "html", + xml: "xml", + svg: "xml", + json: "json", + jsonc: "jsonc", + yaml: "yaml", + yml: "yaml", + toml: "toml", + sh: "bash", + bash: "bash", + zsh: "bash", + sql: "sql", + graphql: "graphql", + gql: "graphql", + vue: "vue", + svelte: "svelte", + lua: "lua", + r: "r", + pl: "perl", + dockerfile: "dockerfile", + mk: "makefile", +}; + +function getExtension(filename: string): string { + const lower = filename.toLowerCase(); + if (lower === "dockerfile" || lower === "makefile" || lower === "gemfile") return lower; + const dot = filename.lastIndexOf("."); + return dot >= 0 ? filename.slice(dot + 1).toLowerCase() : ""; +} + +function isMarkdownFile(filename: string): boolean { + const ext = getExtension(filename); + return ext === "md" || ext === "mdx"; +} + +function getLanguage(filename: string): string { + const ext = getExtension(filename); + return EXTENSION_TO_LANGUAGE[ext] ?? "text"; +} + +// Cross-platform path utilities — handle both `/` (POSIX) and `\` (Windows). +function pathBasename(p: string): string { + const lastSlash = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")); + return lastSlash >= 0 ? p.slice(lastSlash + 1) : p; +} + +function pathDirname(p: string): string { + const lastSlash = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")); + return lastSlash > 0 ? p.slice(0, lastSlash) : ""; +} + +interface BreadcrumbProps { + projectPath: string; + currentDir: string; + onNavigate: (dir: string) => void; +} + +const Breadcrumb: React.FC = ({ projectPath, currentDir, onNavigate }) => { + const rootName = pathBasename(projectPath) || projectPath; + + if (currentDir === projectPath) { + return ( +
+ {rootName} +
+ ); + } + + // Walk upward from currentDir to projectPath, collecting each ancestor. + // This avoids string-splitting on the separator (fragile cross-platform). + const ancestors: { name: string; path: string }[] = []; + let cursor = currentDir; + while (cursor !== projectPath) { + ancestors.unshift({ name: pathBasename(cursor), path: cursor }); + const parent = pathDirname(cursor); + if (!parent || parent === cursor) break; + cursor = parent; + } + + return ( +
+ + {ancestors.map(({ name, path }, i) => { + const isLast = i === ancestors.length - 1; + return ( + + + {isLast ? ( + {name} + ) : ( + + )} + + ); + })} +
+ ); +}; + +interface FileTreeProps { + entries: FileEntry[]; + selectedPath: string | null; + onSelectDir: (entry: FileEntry) => void; + onSelectFile: (entry: FileEntry) => void; +} + +const FileTree: React.FC = ({ entries, selectedPath, onSelectDir, onSelectFile }) => { + // Directories first, then files, both alphabetically. + const sorted = [...entries].sort((a, b) => { + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + if (sorted.length === 0) { + return

Empty directory

; + } + + return ( +
    + {sorted.map((entry) => { + const isSelected = entry.path === selectedPath; + return ( +
  • + +
  • + ); + })} +
+ ); +}; + +interface FileViewerProps { + filename: string; + content: string; + truncated: boolean; +} + +const FileViewer: React.FC = ({ filename, content, truncated }) => { + return ( +
+
+ {filename} + {truncated && ( + (truncated at 512 KB) + )} +
+ {isMarkdownFile(filename) ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ ); +}; + +export const FilesTab: React.FC = ({ projectPath }) => { + const { api } = useAPI(); + const [currentDir, setCurrentDir] = useState(projectPath); + const [entries, setEntries] = useState([]); + const [loadingDir, setLoadingDir] = useState(false); + const [dirError, setDirError] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + const [fileContent, setFileContent] = useState(null); + const [fileTruncated, setFileTruncated] = useState(false); + const [loadingFile, setLoadingFile] = useState(false); + const [fileError, setFileError] = useState(null); + // Incremented on every loadFile call; checked after await to discard stale results. + const fileLoadIdRef = useRef(0); + + const loadDir = useCallback( + async (dirPath: string, signal: AbortSignal) => { + if (!api) return; + setLoadingDir(true); + setDirError(null); + try { + const result = await api.general.listProjectFiles( + { rootPath: projectPath, dirPath }, + { signal } + ); + if (signal.aborted) return; + if (result.success) { + setEntries(result.data); + } else { + setDirError(result.error); + } + } catch (err) { + if (signal.aborted) return; + setDirError(err instanceof Error ? err.message : String(err)); + } finally { + if (!signal.aborted) setLoadingDir(false); + } + }, + [api, projectPath] + ); + + const loadFile = useCallback( + async (entry: FileEntry) => { + if (!api) return; + const loadId = ++fileLoadIdRef.current; + setSelectedFile(entry); + setFileContent(null); + setFileError(null); + setLoadingFile(true); + try { + const result = await api.general.readProjectFile({ + rootPath: projectPath, + filePath: entry.path, + }); + if (fileLoadIdRef.current !== loadId) return; + if (result.success) { + setFileContent(result.data.content); + setFileTruncated(result.data.truncated); + } else { + setFileError(result.error); + } + } catch (err) { + if (fileLoadIdRef.current !== loadId) return; + setFileError(err instanceof Error ? err.message : String(err)); + } finally { + if (fileLoadIdRef.current === loadId) setLoadingFile(false); + } + }, + [api, projectPath] + ); + + const navigateDir = useCallback((entry: FileEntry) => { + setSelectedFile(null); + setFileContent(null); + setFileError(null); + setCurrentDir(entry.path); + }, []); + + const navigateTo = useCallback((dirPath: string) => { + setSelectedFile(null); + setFileContent(null); + setFileError(null); + setCurrentDir(dirPath); + }, []); + + const navigateUp = useCallback(() => { + const parent = pathDirname(currentDir); + if (!parent || parent.length < projectPath.length) return; + navigateTo(parent); + }, [currentDir, projectPath, navigateTo]); + + // Reload directory listing whenever currentDir changes; cancel stale requests. + useEffect(() => { + const controller = new AbortController(); + void loadDir(currentDir, controller.signal); + return () => controller.abort(); + }, [currentDir, loadDir]); + + const atRoot = currentDir === projectPath; + + return ( +
+ {/* Top bar: breadcrumb + up button */} +
+ {!atRoot && ( + + )} + +
+ + {/* Main area: file tree (left) + viewer (right) */} +
+ {/* File tree */} +
+
+ {loadingDir ? ( +

Loading…

+ ) : dirError ? ( +

{dirError}

+ ) : ( + + )} +
+
+ + {/* File viewer */} +
+ {loadingFile ? ( +
+ Loading file… +
+ ) : fileError ? ( +
+ {fileError} +
+ ) : fileContent !== null && selectedFile !== null ? ( + + ) : ( +
+ Select a file to view +
+ )} +
+
+
+ ); +}; diff --git a/src/browser/features/RightSidebar/RightSidebar.tsx b/src/browser/features/RightSidebar/RightSidebar.tsx index 10538f1ab3..530c835286 100644 --- a/src/browser/features/RightSidebar/RightSidebar.tsx +++ b/src/browser/features/RightSidebar/RightSidebar.tsx @@ -673,6 +673,7 @@ const RightSidebarComponent: React.FC = ({ const api = apiState.api; const desktopExperimentEnabled = useExperimentValue(EXPERIMENT_IDS.PORTABLE_DESKTOP); const browserExperimentEnabled = useExperimentValue(EXPERIMENT_IDS.AGENT_BROWSER); + const fileBrowserExperimentEnabled = useExperimentValue(EXPERIMENT_IDS.FILE_BROWSER); // Child task workspaces can't run goal actions — backend rejects them // via `WorkspaceGoalService.assertParentWorkspace`. We use this flag // both to hide the Goal tab below and to gate any inline goal UX. @@ -692,6 +693,7 @@ const RightSidebarComponent: React.FC = ({ const [llmDebugLogsEnabled, setLlmDebugLogsEnabled] = React.useState(null); const [desktopAvailable, setDesktopAvailable] = React.useState(null); const [browserAvailable, setBrowserAvailable] = React.useState(null); + const [fileBrowserAvailable, setFileBrowserAvailable] = React.useState(null); const debugLogsLocalOverrideRef = React.useRef(false); const setGoalWithSingleConflictRetry = async (intent: { @@ -961,6 +963,31 @@ const RightSidebarComponent: React.FC = ({ }); }, [browserAvailable, initialActiveTab, setLayoutRaw]); + React.useEffect(() => { + setFileBrowserAvailable(fileBrowserExperimentEnabled); + }, [fileBrowserExperimentEnabled]); + + React.useEffect(() => { + if (fileBrowserAvailable == null) { + return; + } + + setLayoutRaw((prevRaw) => { + const prev = parseRightSidebarLayoutState(prevRaw, initialActiveTab); + const hasFiles = collectAllTabs(prev.root).includes("files"); + + if (fileBrowserAvailable && !hasFiles) { + return addTabToFocusedTabset(prev, "files", false); + } + + if (!fileBrowserAvailable && hasFiles) { + return removeTabEverywhere(prev, "files"); + } + + return prev; + }); + }, [fileBrowserAvailable, initialActiveTab, setLayoutRaw]); + React.useEffect(() => { setLayoutRaw((prevRaw) => { const prev = parseRightSidebarLayoutState(prevRaw, initialActiveTab); diff --git a/src/browser/features/RightSidebar/Tabs/TabLabels.tsx b/src/browser/features/RightSidebar/Tabs/TabLabels.tsx index e8921801c5..aba6a2a30a 100644 --- a/src/browser/features/RightSidebar/Tabs/TabLabels.tsx +++ b/src/browser/features/RightSidebar/Tabs/TabLabels.tsx @@ -11,6 +11,7 @@ import React from "react"; import { BugPlay, ExternalLink, + FolderOpen, Monitor, Globe, Sparkles, @@ -158,6 +159,14 @@ export const BrowserTabLabel: React.FC = () => ( ); +/** Files tab label with folder icon */ +export const FilesTabLabel: React.FC = () => ( + + + Files + +); + /** Debug tab label with bug icon */ export const DebugTabLabel: React.FC = () => ( diff --git a/src/browser/features/RightSidebar/Tabs/tabConfig.ts b/src/browser/features/RightSidebar/Tabs/tabConfig.ts index 105558a3ec..2612084173 100644 --- a/src/browser/features/RightSidebar/Tabs/tabConfig.ts +++ b/src/browser/features/RightSidebar/Tabs/tabConfig.ts @@ -53,6 +53,13 @@ const TAB_CONFIG_DEF = { defaultOrder: 35, paletteKeywords: ["goal", "target", "objective"], }, + files: { + name: "Files", + contentClassName: "overflow-hidden p-0", + featureFlag: EXPERIMENT_IDS.FILE_BROWSER, + defaultOrder: 45, + paletteKeywords: ["files", "explorer", "file browser", "browse", "readme"], + }, desktop: { name: "Desktop", contentClassName: "overflow-hidden p-0", diff --git a/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx b/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx index 395b6e7168..86a2edbbc6 100644 --- a/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx +++ b/src/browser/features/RightSidebar/Tabs/tabRegistry.tsx @@ -20,6 +20,7 @@ import { StatsContainer } from "@/browser/features/RightSidebar/StatsContainer"; import { ReviewPanel } from "@/browser/features/RightSidebar/CodeReview/ReviewPanel"; import { DesktopPanel } from "@/browser/features/desktop/DesktopPanel"; import { BrowserTab } from "@/browser/features/RightSidebar/BrowserTab"; +import { FilesTab } from "@/browser/features/RightSidebar/FilesTab/FilesTab"; import { DevToolsTab } from "@/browser/features/RightSidebar/DevToolsTab"; import { GoalTab, type GoalCreateIntent } from "@/browser/features/RightSidebar/GoalTab"; import type { GoalSnapshot, GoalStatus } from "@/common/types/goal"; @@ -29,6 +30,7 @@ import { BrowserTabLabel, DebugTabLabel, DesktopTabLabel, + FilesTabLabel, GoalTabLabel, InstructionsTabLabel, OutputTabLabel, @@ -165,6 +167,14 @@ const TAB_RENDERERS = { ), }, + files: { + Label: FilesTabLabel, + renderPanel: (ctx) => ( + + + + ), + }, browser: { Label: BrowserTabLabel, renderPanel: (ctx) => ( diff --git a/src/common/constants/experiments.ts b/src/common/constants/experiments.ts index 793e7fb189..79f6835ccc 100644 --- a/src/common/constants/experiments.ts +++ b/src/common/constants/experiments.ts @@ -18,6 +18,7 @@ export const EXPERIMENT_IDS = { PORTABLE_DESKTOP: "portable-desktop", DYNAMIC_WORKFLOWS: "dynamic-workflows", SUBAGENT_FILE_REPORTS: "subagent-file-reports", + FILE_BROWSER: "file-browser", } as const; export type ExperimentId = (typeof EXPERIMENT_IDS)[keyof typeof EXPERIMENT_IDS]; @@ -150,6 +151,15 @@ export const EXPERIMENTS: Record = { userOverridable: true, showInSettings: true, }, + [EXPERIMENT_IDS.FILE_BROWSER]: { + id: EXPERIMENT_IDS.FILE_BROWSER, + name: "File Browser", + description: + "Show a Files tab in the right sidebar to browse and read project files (markdown, code, etc.) inline", + enabledByDefault: false, + userOverridable: true, + showInSettings: true, + }, }; function getPlatformDisplayName(platform: NodeJS.Platform): string { diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index f0864fb670..f9d7ea9921 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -2547,6 +2547,28 @@ export const general = { input: z.object({ path: z.string() }), output: ResultSchema(FileTreeNodeSchema), }, + /** + * List files and directories immediately inside `dirPath`, which must be + * within (or equal to) `rootPath`. Used by the Files tab file browser. + */ + listProjectFiles: { + input: z.object({ rootPath: z.string(), dirPath: z.string() }), + output: ResultSchema( + z.array(z.object({ name: z.string(), path: z.string(), isDirectory: z.boolean() })), + z.string() + ), + }, + /** + * Read the text content of a file within a project root. + * Binary files are rejected; files over 512 KB are truncated. + */ + readProjectFile: { + input: z.object({ rootPath: z.string(), filePath: z.string() }), + output: ResultSchema( + z.object({ content: z.string(), truncated: z.boolean() }), + z.string() + ), + }, /** * Create a directory at the specified path. * Creates parent directories recursively if they don't exist (like mkdir -p). diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index d315d0668d..2ca8b9a21f 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -2527,6 +2527,18 @@ export const router = (authToken?: string) => { .handler(({ context }) => { return context.windowService.restartApp(); }), + listProjectFiles: t + .input(schemas.general.listProjectFiles.input) + .output(schemas.general.listProjectFiles.output) + .handler(async ({ context, input }) => { + return context.projectService.listProjectFiles(input.rootPath, input.dirPath); + }), + readProjectFile: t + .input(schemas.general.readProjectFile.input) + .output(schemas.general.readProjectFile.output) + .handler(async ({ context, input }) => { + return context.projectService.readProjectFile(input.rootPath, input.filePath); + }), openInEditor: t .input(schemas.general.openInEditor.input) .output(schemas.general.openInEditor.output) diff --git a/src/node/services/projectService.ts b/src/node/services/projectService.ts index 521657cc24..8be76b88dc 100644 --- a/src/node/services/projectService.ts +++ b/src/node/services/projectService.ts @@ -75,6 +75,83 @@ async function listDirectory(requestedPath: string): Promise { }; } +// Maximum bytes to read when serving a file to the frontend. +// Guards against accidentally streaming huge files over IPC. +const MAX_READ_BYTES = 512 * 1024; + +/** + * Verify `targetPath` is contained within `rootPath` (no directory traversal). + * Uses `path.relative` so it works correctly on both POSIX and Windows. + */ +function isWithinRoot(rootPath: string, targetPath: string): boolean { + const rel = path.relative(rootPath, targetPath); + return !rel.startsWith("..") && !path.isAbsolute(rel); +} + +/** + * List immediate children (files and directories) of `dirPath` inside `rootPath`. + */ +async function listProjectFiles( + rootPath: string, + dirPath: string +): Promise> { + const normalizedRoot = path.resolve(rootPath); + const normalizedDir = path.resolve(dirPath); + + if (normalizedDir !== normalizedRoot && !isWithinRoot(normalizedRoot, normalizedDir)) { + throw new Error("Access denied: path is outside the project root"); + } + + const entries = await fsPromises.readdir(normalizedDir, { withFileTypes: true }); + return entries.map((entry) => ({ + name: entry.name, + path: path.join(normalizedDir, entry.name), + isDirectory: entry.isDirectory(), + })); +} + +/** + * Read a text file within `rootPath`. Rejects binary files and truncates files + * over MAX_READ_BYTES. Returns the content and whether it was truncated. + */ +async function readProjectFile( + rootPath: string, + filePath: string +): Promise<{ content: string; truncated: boolean }> { + const normalizedRoot = path.resolve(rootPath); + const normalizedFile = path.resolve(filePath); + + if (!isWithinRoot(normalizedRoot, normalizedFile)) { + throw new Error("Access denied: path is outside the project root"); + } + + const stat = await fsPromises.stat(normalizedFile); + if (stat.isDirectory()) { + throw new Error("Cannot read a directory as a file"); + } + + const bytesToRead = Math.min(stat.size, MAX_READ_BYTES); + const truncated = stat.size > MAX_READ_BYTES; + + const handle = await fsPromises.open(normalizedFile, "r"); + try { + const buffer = Buffer.alloc(bytesToRead); + await handle.read(buffer, 0, bytesToRead, 0); + + // Detect binary by scanning the first 8 KB for null bytes. + const sampleSize = Math.min(bytesToRead, 8192); + for (let i = 0; i < sampleSize; i++) { + if (buffer[i] === 0) { + throw new Error("Binary files cannot be displayed as text"); + } + } + + return { content: buffer.toString("utf-8"), truncated }; + } finally { + await handle.close(); + } +} + interface CloneProjectParams { repoUrl: string; cloneParentDir?: string | null; @@ -1315,6 +1392,24 @@ export class ProjectService { } } + async listProjectFiles(rootPath: string, dirPath: string) { + try { + const entries = await listProjectFiles(rootPath, dirPath); + return { success: true as const, data: entries }; + } catch (error) { + return { success: false as const, error: getErrorMessage(error) }; + } + } + + async readProjectFile(rootPath: string, filePath: string) { + try { + const result = await readProjectFile(rootPath, filePath); + return { success: true as const, data: result }; + } catch (error) { + return { success: false as const, error: getErrorMessage(error) }; + } + } + async createDirectory( requestedPath: string ): Promise> {