diff --git a/src/browser/components/RightSidebar.tsx b/src/browser/components/RightSidebar.tsx index 1036865ac5..1ce20c0e03 100644 --- a/src/browser/components/RightSidebar.tsx +++ b/src/browser/components/RightSidebar.tsx @@ -3,10 +3,13 @@ import { RIGHT_SIDEBAR_TAB_KEY, RIGHT_SIDEBAR_COLLAPSED_KEY } from "@/common/con import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { useWorkspaceUsage, useWorkspaceStatsSnapshot } from "@/browser/stores/WorkspaceStore"; import { useFeatureFlags } from "@/browser/contexts/FeatureFlagsContext"; +import { useExperimentValue } from "@/browser/contexts/ExperimentsContext"; +import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { ErrorBoundary } from "./ErrorBoundary"; import { CostsTab } from "./RightSidebar/CostsTab"; import { StatsTab } from "./RightSidebar/StatsTab"; import { ReviewPanel } from "./RightSidebar/CodeReview/ReviewPanel"; +import { FilePanel } from "./RightSidebar/FilePanel"; import { sumUsageHistory, type ChatUsageDisplay } from "@/common/utils/tokens/usageAggregator"; import { matchesKeybind, KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds"; import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; @@ -84,7 +87,7 @@ const SidebarContainer: React.FC = ({ ); }; -type TabType = "costs" | "stats" | "review"; +type TabType = "costs" | "stats" | "review" | "files"; export type { TabType }; @@ -122,12 +125,16 @@ const RightSidebarComponent: React.FC = ({ const { statsTabState } = useFeatureFlags(); const statsTabEnabled = Boolean(statsTabState?.enabled); + const filePanelEnabled = useExperimentValue(EXPERIMENT_IDS.FILE_PANEL); React.useEffect(() => { if (!statsTabEnabled && selectedTab === "stats") { setSelectedTab("costs"); } - }, [statsTabEnabled, selectedTab, setSelectedTab]); + if (!filePanelEnabled && selectedTab === "files") { + setSelectedTab("costs"); + } + }, [statsTabEnabled, filePanelEnabled, selectedTab, setSelectedTab]); // Trigger for focusing Review panel (preserves hunk selection) const [focusTrigger, setFocusTrigger] = React.useState(0); @@ -151,12 +158,16 @@ const RightSidebarComponent: React.FC = ({ e.preventDefault(); setSelectedTab("stats"); setCollapsed(false); + } else if (filePanelEnabled && matchesKeybind(e, KEYBINDS.FILES_TAB)) { + e.preventDefault(); + setSelectedTab("files"); + setCollapsed(false); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [setSelectedTab, setCollapsed, statsTabEnabled]); + }, [setSelectedTab, setCollapsed, statsTabEnabled, filePanelEnabled]); const usage = useWorkspaceUsage(workspaceId); @@ -164,9 +175,11 @@ const RightSidebarComponent: React.FC = ({ const costsTabId = `${baseId}-tab-costs`; const statsTabId = `${baseId}-tab-stats`; const reviewTabId = `${baseId}-tab-review`; + const filesTabId = `${baseId}-tab-files`; const costsPanelId = `${baseId}-panel-costs`; const statsPanelId = `${baseId}-panel-stats`; const reviewPanelId = `${baseId}-panel-review`; + const filesPanelId = `${baseId}-panel-files`; // Calculate session cost for tab display const sessionCost = React.useMemo(() => { @@ -201,7 +214,7 @@ const RightSidebarComponent: React.FC = ({ return ( = ({ )} + {filePanelEnabled && ( + + + + + + {formatKeybind(KEYBINDS.FILES_TAB)} + + + )}
{selectedTab === "costs" && (
@@ -353,6 +394,18 @@ const RightSidebarComponent: React.FC = ({
)} + {filePanelEnabled && selectedTab === "files" && ( +
+ + + +
+ )}
)} diff --git a/src/browser/components/RightSidebar/FilePanel/index.tsx b/src/browser/components/RightSidebar/FilePanel/index.tsx new file mode 100644 index 0000000000..8a5a46a70f --- /dev/null +++ b/src/browser/components/RightSidebar/FilePanel/index.tsx @@ -0,0 +1,340 @@ +/** + * FilePanel - File browser panel for the right sidebar + * + * Displays a file tree on the right and file content on the left (IDE-style layout). + * Content is syntax highlighted using Shiki. + */ + +import React from "react"; +import { useAPI } from "@/browser/contexts/API"; +import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { + getFilePanelExpandStateKey, + getFilePanelSelectedFileKey, +} from "@/common/constants/storage"; +import { cn } from "@/common/lib/utils"; +import { FileIcon } from "@/browser/components/FileIcon"; +import { highlightCode } from "@/browser/utils/highlighting/highlightWorkerClient"; +import { useTheme } from "@/browser/contexts/ThemeContext"; +import type { FileTreeNode } from "@/common/utils/git/numstatParser"; + +interface FilePanelProps { + workspaceId: string; +} + +/** Tree node component for rendering file/folder entries */ +const TreeNode: React.FC<{ + node: FileTreeNode; + depth: number; + selectedPath: string | null; + onSelectFile: (path: string) => void; + expandStateMap: Record; + setExpandStateMap: ( + value: Record | ((prev: Record) => Record) + ) => void; +}> = ({ node, depth, selectedPath, onSelectFile, expandStateMap, setExpandStateMap }) => { + const hasManualState = node.path in expandStateMap; + const isOpen = hasManualState ? expandStateMap[node.path] : depth < 2; + + const setIsOpen = (open: boolean) => { + setExpandStateMap((prev) => ({ + ...prev, + [node.path]: open, + })); + }; + + const handleClick = (e: React.MouseEvent) => { + if (node.isDirectory) { + const target = e.target as HTMLElement; + const isToggleClick = target.closest("[data-toggle]"); + if (isToggleClick) { + setIsOpen(!isOpen); + } else { + setIsOpen(!isOpen); + } + } else { + onSelectFile(node.path); + } + }; + + const handleToggleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsOpen(!isOpen); + }; + + const isSelected = selectedPath === node.path; + + return ( + <> +
+ {node.isDirectory ? ( + <> + + ▶ + + {node.name || "/"} + + ) : ( + <> + + {node.name} + + )} +
+ + {node.isDirectory && + isOpen && + node.children.map((child) => ( + + ))} + + ); +}; + +/** File content viewer with syntax highlighting */ +const FileContentViewer: React.FC<{ + workspaceId: string; + filePath: string; + onClose: () => void; +}> = ({ workspaceId, filePath, onClose }) => { + const api = useAPI(); + const { theme } = useTheme(); + const [content, setContent] = React.useState(null); + const [highlightedContent, setHighlightedContent] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [truncated, setTruncated] = React.useState(false); + const [totalSize, setTotalSize] = React.useState(0); + + // Load file content + React.useEffect(() => { + if (api.status !== "connected" && api.status !== "degraded") return; + + let cancelled = false; + setLoading(true); + setError(null); + + api.api.workspace + .readFile({ workspaceId, path: filePath }) + .then(async (result) => { + if (cancelled) return; + + if (!result.success) { + setError(result.error); + setLoading(false); + return; + } + + setContent(result.data.content); + setTruncated(result.data.truncated); + setTotalSize(result.data.totalSize); + + // Highlight the content + try { + const isDarkTheme = theme === "dark" || theme === "solarized-dark"; + const highlighted = await highlightCode( + result.data.content, + result.data.language, + isDarkTheme ? "dark" : "light" + ); + if (!cancelled) { + setHighlightedContent(highlighted); + } + } catch { + // Fallback to plain text on highlight failure + if (!cancelled) { + setHighlightedContent(null); + } + } + + setLoading(false); + }) + .catch((err) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [api, workspaceId, filePath, theme]); + + const fileName = filePath.split("/").pop() ?? filePath; + + return ( +
+ {/* Header */} +
+ + + {fileName} + + {truncated && ( + + truncated + + )} + +
+ + {/* Content */} +
+ {loading ? ( +
Loading...
+ ) : error ? ( +
{error}
+ ) : highlightedContent ? ( +
+ ) : ( +
{content}
+ )} +
+
+ ); +}; + +export const FilePanel: React.FC = ({ workspaceId }) => { + const api = useAPI(); + + // File tree state + const [fileTree, setFileTree] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + // Selected file (persisted per workspace) + const [selectedFile, setSelectedFile] = usePersistedState( + getFilePanelSelectedFileKey(workspaceId), + null + ); + + // Expand state for tree (persisted per workspace) + const [expandStateMap, setExpandStateMap] = usePersistedState>( + getFilePanelExpandStateKey(workspaceId), + {}, + { listener: true } + ); + + // Load file tree + React.useEffect(() => { + if (api.status !== "connected" && api.status !== "degraded") return; + + let cancelled = false; + setLoading(true); + setError(null); + + api.api.workspace + .listFiles({ workspaceId }) + .then((result) => { + if (cancelled) return; + + if (!result.success) { + setError(result.error); + setLoading(false); + return; + } + + setFileTree(result.data); + setLoading(false); + }) + .catch((err) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [api, workspaceId]); + + const handleSelectFile = (path: string) => { + setSelectedFile(path); + }; + + const handleCloseFile = () => { + setSelectedFile(null); + }; + + return ( +
+ {/* File content viewer (left side) */} + {selectedFile && ( +
+ +
+ )} + + {/* File tree (right side) */} +
+ {/* Header */} +
+ Files +
+ + {/* Tree content */} +
+ {loading ? ( +
Loading files...
+ ) : error ? ( +
{error}
+ ) : fileTree ? ( + fileTree.children.map((child) => ( + + )) + ) : ( +
No files in workspace
+ )} +
+
+
+ ); +}; diff --git a/src/browser/components/Settings/sections/KeybindsSection.tsx b/src/browser/components/Settings/sections/KeybindsSection.tsx index 9cc94c4118..fc5eeee4cb 100644 --- a/src/browser/components/Settings/sections/KeybindsSection.tsx +++ b/src/browser/components/Settings/sections/KeybindsSection.tsx @@ -30,6 +30,7 @@ const KEYBIND_LABELS: Record = { COSTS_TAB: "Costs tab", REVIEW_TAB: "Review tab", STATS_TAB: "Stats tab", + FILES_TAB: "Files tab", REFRESH_REVIEW: "Refresh diff", FOCUS_REVIEW_SEARCH: "Search in review", TOGGLE_HUNK_READ: "Toggle hunk read", @@ -79,7 +80,7 @@ const KEYBIND_GROUPS: Array<{ label: string; keys: Array }, { label: "Tabs", - keys: ["COSTS_TAB", "REVIEW_TAB", "STATS_TAB"], + keys: ["COSTS_TAB", "REVIEW_TAB", "STATS_TAB", "FILES_TAB"], }, { label: "Code Review", diff --git a/src/browser/stories/App.rightSidebar.stories.tsx b/src/browser/stories/App.rightSidebar.stories.tsx index 4a44baeee9..21a08cd433 100644 --- a/src/browser/stories/App.rightSidebar.stories.tsx +++ b/src/browser/stories/App.rightSidebar.stories.tsx @@ -18,7 +18,9 @@ import { RIGHT_SIDEBAR_TAB_KEY, RIGHT_SIDEBAR_COSTS_WIDTH_KEY, RIGHT_SIDEBAR_REVIEW_WIDTH_KEY, + RIGHT_SIDEBAR_FILES_WIDTH_KEY, } from "@/common/constants/storage"; +import { getExperimentKey, EXPERIMENT_IDS } from "@/common/constants/experiments"; import type { ComponentType } from "react"; import type { MockSessionUsage } from "@/browser/stories/mocks/orpc"; @@ -918,3 +920,117 @@ export const ReviewTabReadMoreBoundaries: AppStory = { ), // No play function - interaction testing covered by tests/ui/readMore.integration.test.ts }; + +// ═══════════════════════════════════════════════════════════════════════════════ +// FILES TAB STORIES (requires FILE_PANEL experiment) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Files tab - basic file tree display with experiment enabled + */ +export const FilesTab: AppStory = { + render: () => ( + { + // Enable the FILE_PANEL experiment + const experimentKey = getExperimentKey(EXPERIMENT_IDS.FILE_PANEL); + localStorage.setItem(experimentKey, JSON.stringify(true)); + + localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("files")); + localStorage.setItem(RIGHT_SIDEBAR_FILES_WIDTH_KEY, "600"); + + const client = setupSimpleChatStory({ + workspaceId: "ws-files", + workspaceName: "feature/file-browser", + projectName: "my-app", + messages: [ + createUserMessage("msg-1", "Show me the project structure", { historySequence: 1 }), + createAssistantMessage("msg-2", "Here's the project structure in the Files panel.", { + historySequence: 2, + }), + ], + }); + expandRightSidebar(); + return client; + }} + /> + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for Files tab to be visible and selected + await waitFor( + async () => { + const tab = canvas.getByRole("tab", { name: /^files/i }); + await expect(tab).toHaveAttribute("aria-selected", "true"); + }, + { timeout: 5000 } + ); + + // Verify file tree structure is visible + await waitFor( + () => { + canvas.getByText("src"); + }, + { timeout: 3000 } + ); + }, +}; + +/** + * Files tab with file selected - shows file content viewer + */ +export const FilesTabWithFileSelected: AppStory = { + render: () => ( + { + // Enable the FILE_PANEL experiment + const experimentKey = getExperimentKey(EXPERIMENT_IDS.FILE_PANEL); + localStorage.setItem(experimentKey, JSON.stringify(true)); + + localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("files")); + localStorage.setItem(RIGHT_SIDEBAR_FILES_WIDTH_KEY, "600"); + + // Pre-select a file + localStorage.setItem( + "filePanelSelectedFile:ws-files-selected", + JSON.stringify("src/App.tsx") + ); + + const client = setupSimpleChatStory({ + workspaceId: "ws-files-selected", + workspaceName: "feature/file-viewer", + projectName: "my-app", + messages: [ + createUserMessage("msg-1", "Show me the App component", { historySequence: 1 }), + createAssistantMessage("msg-2", "The App.tsx file is shown in the Files panel.", { + historySequence: 2, + }), + ], + }); + expandRightSidebar(); + return client; + }} + /> + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for Files tab to be visible + await waitFor( + () => { + canvas.getByRole("tab", { name: /^files/i, selected: true }); + }, + { timeout: 5000 } + ); + + // Wait for file content to load (look for file header with filename) + await waitFor( + () => { + // The file viewer shows the filename in the header + canvas.getByText("App.tsx"); + }, + { timeout: 5000 } + ); + }, +}; diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index a1b2c4bca1..6776d73575 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -28,6 +28,7 @@ import { import { normalizeModeAiDefaults, type ModeAiDefaults } from "@/common/types/modeAiDefaults"; import { normalizeAgentAiDefaults, type AgentAiDefaults } from "@/common/types/agentAiDefaults"; import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue"; +import type { FileTreeNode } from "@/common/utils/git/numstatParser"; import { isWorkspaceArchived } from "@/common/utils/archive"; /** Session usage data structure matching SessionUsageFileSchema */ @@ -130,6 +131,10 @@ export interface MockORPCClientOptions { gitInit?: (input: { projectPath: string; }) => Promise<{ success: true } | { success: false; error: string }>; + /** Mock file tree for workspace.listFiles */ + fileTree?: FileTreeNode; + /** Mock file contents for workspace.readFile - maps path to content */ + fileContents?: Map; /** Idle compaction hours per project (null = disabled) */ idleCompactionHours?: Map; /** Override signing capabilities response */ @@ -161,6 +166,28 @@ interface MockMcpOverrides { toolAllowlist?: Record; } +/** + * Helper to detect language from file extension for syntax highlighting + */ +function detectLanguageFromExtension(path: string): string { + const ext = path.split(".").pop()?.toLowerCase() ?? ""; + const extMap: Record = { + ts: "typescript", + tsx: "tsx", + js: "javascript", + jsx: "jsx", + json: "json", + md: "markdown", + py: "python", + css: "css", + html: "html", + sh: "bash", + yml: "yaml", + yaml: "yaml", + }; + return extMap[ext] ?? "plaintext"; +} + type MockMcpTestResult = { success: true; tools: string[] } | { success: false; error: string }; /** @@ -643,6 +670,70 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl const filtered = mockPaths.filter((p) => p.toLowerCase().includes(query)); return Promise.resolve({ paths: filtered.slice(0, input.limit ?? 20) }); }, + listFiles: () => { + if (options.fileTree) { + return Promise.resolve({ success: true, data: options.fileTree }); + } + // Default mock file tree + const defaultTree: FileTreeNode = { + name: "", + path: "", + isDirectory: true, + children: [ + { + name: "src", + path: "src", + isDirectory: true, + children: [ + { + name: "index.ts", + path: "src/index.ts", + isDirectory: false, + children: [], + }, + { + name: "App.tsx", + path: "src/App.tsx", + isDirectory: false, + children: [], + }, + ], + }, + { + name: "README.md", + path: "README.md", + isDirectory: false, + children: [], + }, + ], + }; + return Promise.resolve({ success: true, data: defaultTree }); + }, + readFile: (input: { workspaceId: string; path: string; maxBytes?: number }) => { + const fileContents = options.fileContents ?? new Map(); + const content = fileContents.get(input.path); + if (content !== undefined) { + return Promise.resolve({ + success: true, + data: { + content, + language: detectLanguageFromExtension(input.path), + truncated: false, + totalSize: content.length, + }, + }); + } + // Default mock content + return Promise.resolve({ + success: true, + data: { + content: `// Content of ${input.path}\n\nconsole.log("Hello from ${input.path}");`, + language: detectLanguageFromExtension(input.path), + truncated: false, + totalSize: 100, + }, + }); + }, }, window: { setTitle: () => Promise.resolve(undefined), diff --git a/src/browser/utils/ui/keybinds.ts b/src/browser/utils/ui/keybinds.ts index 16e85e4f12..662113277d 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -280,6 +280,10 @@ export const KEYBINDS = { // macOS: Cmd+3, Win/Linux: Ctrl+3 STATS_TAB: { key: "3", ctrl: true, description: "Stats tab" }, + /** Switch to Files tab in right sidebar */ + // macOS: Cmd+4, Win/Linux: Ctrl+4 + FILES_TAB: { key: "4", ctrl: true, description: "Files tab" }, + /** Refresh diff in Code Review panel */ // macOS: Cmd+R, Win/Linux: Ctrl+R REFRESH_REVIEW: { key: "r", ctrl: true }, diff --git a/src/common/constants/experiments.ts b/src/common/constants/experiments.ts index 81515c0fdc..a6b2ca9fc5 100644 --- a/src/common/constants/experiments.ts +++ b/src/common/constants/experiments.ts @@ -10,6 +10,7 @@ export const EXPERIMENT_IDS = { PROGRAMMATIC_TOOL_CALLING: "programmatic-tool-calling", PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE: "programmatic-tool-calling-exclusive", CONFIGURABLE_BIND_URL: "configurable-bind-url", + FILE_PANEL: "file-panel", } as const; export type ExperimentId = (typeof EXPERIMENT_IDS)[keyof typeof EXPERIMENT_IDS]; @@ -70,6 +71,14 @@ export const EXPERIMENTS: Record = { userOverridable: true, showInSettings: true, }, + [EXPERIMENT_IDS.FILE_PANEL]: { + id: EXPERIMENT_IDS.FILE_PANEL, + name: "File Panel", + description: "Show a file browser panel in the right sidebar for browsing workspace files", + enabledByDefault: false, + userOverridable: true, + showInSettings: true, + }, }; /** diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 8697f8baee..296169225c 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -314,6 +314,24 @@ export function getFileTreeExpandStateKey(workspaceId: string): string { return `fileTreeExpandState:${workspaceId}`; } +/** + * Get the localStorage key for File Panel tree expand/collapse state per workspace + * Stores directory expand/collapse preferences for the Files tab + * Format: "filePanelExpandState:{workspaceId}" + */ +export function getFilePanelExpandStateKey(workspaceId: string): string { + return `filePanelExpandState:${workspaceId}`; +} + +/** + * Get the localStorage key for File Panel selected file per workspace + * Stores the currently selected file path for viewing + * Format: "filePanelSelectedFile:{workspaceId}" + */ +export function getFilePanelSelectedFileKey(workspaceId: string): string { + return `filePanelSelectedFile:${workspaceId}`; +} + /** * Get the localStorage key for persisted agent status for a workspace * Stores the most recent successful status_set payload (emoji, message, url) @@ -357,6 +375,12 @@ export const RIGHT_SIDEBAR_COSTS_WIDTH_KEY = "right-sidebar:width:costs"; */ export const RIGHT_SIDEBAR_REVIEW_WIDTH_KEY = "review-sidebar-width"; +/** + * Right sidebar width for Files tab (global) + * Format: "right-sidebar:width:files" + */ +export const RIGHT_SIDEBAR_FILES_WIDTH_KEY = "right-sidebar:width:files"; + /** * Get the localStorage key for unified Review search state per workspace * Stores: { input: string, useRegex: boolean, matchCase: boolean } @@ -410,6 +434,8 @@ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> getReviewExpandStateKey, getReviewReadMoreKey, getFileTreeExpandStateKey, + getFilePanelExpandStateKey, + getFilePanelSelectedFileKey, getReviewSearchStateKey, getReviewsKey, getAutoCompactionEnabledKey, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index a53156c714..9483dbcf82 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -580,6 +580,42 @@ export const workspace = { output: ResultSchema(z.void(), z.string()), }, }, + /** + * List files in a workspace for the file browser panel. + * Returns a hierarchical file tree structure. + */ + listFiles: { + input: z.object({ + workspaceId: z.string(), + /** When true, only show files that have git changes (vs trunk) */ + gitChangesOnly: z.boolean().optional(), + }), + output: ResultSchema(FileTreeNodeSchema.nullable(), z.string()), + }, + /** + * Read file content from a workspace for the file browser panel. + */ + readFile: { + input: z.object({ + workspaceId: z.string(), + /** Path relative to workspace root */ + path: z.string(), + /** Maximum bytes to read (default 1MB for performance) */ + maxBytes: z.number().int().positive().optional(), + }), + output: ResultSchema( + z.object({ + content: z.string(), + /** Detected programming language for syntax highlighting */ + language: z.string(), + /** True if file was truncated due to size limits */ + truncated: z.boolean(), + /** Total file size in bytes */ + totalSize: z.number(), + }), + z.string() + ), + }, }; export type WorkspaceSendMessageOutput = z.infer; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 3e7ea12838..44f81499f9 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -16,7 +16,7 @@ import { createAuthMiddleware } from "./authMiddleware"; import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue"; import { createRuntime } from "@/node/runtime/runtimeFactory"; -import { readPlanFile } from "@/node/utils/runtime/helpers"; +import { readPlanFile, execBuffered, readFileString } from "@/node/utils/runtime/helpers"; import { secretsToRecord } from "@/common/types/secrets"; import { roundToBase2 } from "@/common/telemetry/utils"; import { createAsyncEventQueue } from "@/common/utils/asyncEventIterator"; @@ -33,6 +33,133 @@ import { } from "@/node/services/agentDefinitions/agentDefinitionsService"; import { resolveAgentInheritanceChain } from "@/node/services/agentDefinitions/resolveAgentInheritanceChain"; import { isWorkspaceArchived } from "@/common/utils/archive"; +import type { FileTreeNode } from "@/common/utils/git/numstatParser"; + +/** + * Build a hierarchical file tree from a list of file paths. + * Simplified version of buildFileTree from numstatParser (without diff stats). + */ +function buildFileTreeFromPaths(files: string[]): FileTreeNode { + const root: FileTreeNode = { + name: "", + path: "", + isDirectory: true, + children: [], + }; + + for (const filePath of files) { + const parts = filePath.split("/"); + let current = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isFile = i === parts.length - 1; + const currentPath = parts.slice(0, i + 1).join("/"); + + let child = current.children.find((c) => c.name === part); + if (!child) { + child = { + name: part, + path: currentPath, + isDirectory: !isFile, + children: [], + }; + current.children.push(child); + } + current = child; + } + } + + // Sort children: directories first, then alphabetically + const sortChildren = (node: FileTreeNode) => { + node.children.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + node.children.forEach(sortChildren); + }; + sortChildren(root); + + return root; +} + +/** + * Detect programming language from file path for syntax highlighting. + */ +function detectLanguageFromPath(path: string): string { + const ext = path.split(".").pop()?.toLowerCase() ?? ""; + const extMap: Record = { + ts: "typescript", + tsx: "tsx", + js: "javascript", + jsx: "jsx", + json: "json", + md: "markdown", + py: "python", + rb: "ruby", + go: "go", + rs: "rust", + java: "java", + c: "c", + cpp: "cpp", + h: "c", + hpp: "cpp", + cs: "csharp", + css: "css", + scss: "scss", + less: "less", + html: "html", + xml: "xml", + yaml: "yaml", + yml: "yaml", + toml: "toml", + sh: "bash", + bash: "bash", + zsh: "bash", + fish: "fish", + ps1: "powershell", + sql: "sql", + graphql: "graphql", + gql: "graphql", + vue: "vue", + svelte: "svelte", + php: "php", + swift: "swift", + kt: "kotlin", + kts: "kotlin", + scala: "scala", + clj: "clojure", + ex: "elixir", + exs: "elixir", + erl: "erlang", + hs: "haskell", + lua: "lua", + r: "r", + jl: "julia", + dart: "dart", + dockerfile: "dockerfile", + makefile: "makefile", + cmake: "cmake", + txt: "plaintext", + log: "plaintext", + conf: "ini", + ini: "ini", + env: "dotenv", + gitignore: "gitignore", + editorconfig: "editorconfig", + }; + + // Handle special filenames + const fileName = path.split("/").pop()?.toLowerCase() ?? ""; + if (fileName === "dockerfile") return "dockerfile"; + if (fileName === "makefile" || fileName === "gnumakefile") return "makefile"; + if (fileName === ".gitignore") return "gitignore"; + if (fileName === ".env" || fileName.startsWith(".env.")) return "dotenv"; + + return extMap[ext] ?? "plaintext"; +} /** * Resolves runtime and discovery path for agent operations. @@ -1566,6 +1693,132 @@ export const router = (authToken?: string) => { } }), }, + listFiles: t + .input(schemas.workspace.listFiles.input) + .output(schemas.workspace.listFiles.output) + .handler(async ({ context, input }) => { + try { + const metadataResult = await context.aiService.getWorkspaceMetadata(input.workspaceId); + if (!metadataResult.success) { + return { success: false, error: metadataResult.error }; + } + const metadata = metadataResult.data; + const runtime = createRuntime(metadata.runtimeConfig, { + projectPath: metadata.projectPath, + }); + const workspacePath = runtime.getWorkspacePath(metadata.projectPath, metadata.name); + + // Use git ls-files for tracked files (works across all runtime types) + // For gitChangesOnly, compare against HEAD (uncommitted changes) + const gitCommand = input.gitChangesOnly + ? "git diff --name-only HEAD 2>/dev/null || true" + : "git ls-files 2>/dev/null || true"; + + const result = await execBuffered(runtime, gitCommand, { + cwd: workspacePath, + timeout: 30000, + }); + if (result.exitCode !== 0) { + return { success: false, error: result.stderr || "Failed to list files" }; + } + + const files = result.stdout + .split("\n") + .map((f) => f.trim()) + .filter((f) => f.length > 0); + + if (files.length === 0) { + return { success: true, data: null }; + } + + // Build file tree structure (reusing pattern from review panel) + const fileTree = buildFileTreeFromPaths(files); + return { success: true, data: fileTree }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; + } + }), + readFile: t + .input(schemas.workspace.readFile.input) + .output(schemas.workspace.readFile.output) + .handler(async ({ context, input }) => { + try { + const metadataResult = await context.aiService.getWorkspaceMetadata(input.workspaceId); + if (!metadataResult.success) { + return { success: false, error: metadataResult.error }; + } + const metadata = metadataResult.data; + const runtime = createRuntime(metadata.runtimeConfig, { + projectPath: metadata.projectPath, + }); + const workspacePath = runtime.getWorkspacePath(metadata.projectPath, metadata.name); + + // Security: prevent path traversal + // Normalize the path and check for ../ segments (not just ".." substring which blocks valid filenames like "foo..bar.ts") + const normalizedPath = input.path.replace(/\\/g, "/"); + const pathSegments = normalizedPath.split("/"); + const hasTraversal = pathSegments.some( + (segment) => segment === ".." || segment === "." + ); + if (hasTraversal || normalizedPath.startsWith("/")) { + return { success: false, error: "Invalid path: path traversal not allowed" }; + } + + const fullPath = `${workspacePath}/${normalizedPath}`; + const maxBytes = input.maxBytes ?? 1024 * 1024; // 1MB default + + // Get file size first using runtime.stat() + let totalSize = 0; + let truncated = false; + try { + const stat = await runtime.stat(fullPath); + totalSize = stat.size; + truncated = totalSize > maxBytes; + } catch { + // File might not exist or be unreadable - continue to try reading + } + + // Read file content using runtime.readFile (supports truncation via streaming) + let content: string; + try { + if (truncated) { + // For truncated files, use head command + const headResult = await execBuffered( + runtime, + `head -c ${maxBytes} ${JSON.stringify(fullPath)}`, + { cwd: workspacePath, timeout: 30000 } + ); + if (headResult.exitCode !== 0) { + return { success: false, error: `Failed to read file: ${headResult.stderr}` }; + } + content = headResult.stdout; + } else { + // For full files, use the streaming readFile + content = await readFileString(runtime, fullPath); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return { success: false, error: `Failed to read file: ${msg}` }; + } + + // Detect language from file extension + const language = detectLanguageFromPath(normalizedPath); + + return { + success: true, + data: { + content, + language, + truncated, + totalSize, + }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; + } + }), }, tasks: { create: t