Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
400 changes: 400 additions & 0 deletions src/browser/features/RightSidebar/FilesTab/FilesTab.tsx

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions src/browser/features/RightSidebar/RightSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,7 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
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.
Expand All @@ -692,6 +693,7 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
const [llmDebugLogsEnabled, setLlmDebugLogsEnabled] = React.useState<boolean | null>(null);
const [desktopAvailable, setDesktopAvailable] = React.useState<boolean | null>(null);
const [browserAvailable, setBrowserAvailable] = React.useState<boolean | null>(null);
const [fileBrowserAvailable, setFileBrowserAvailable] = React.useState<boolean | null>(null);
const debugLogsLocalOverrideRef = React.useRef(false);

const setGoalWithSingleConflictRetry = async (intent: {
Expand Down Expand Up @@ -961,6 +963,31 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
});
}, [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);
Expand Down
9 changes: 9 additions & 0 deletions src/browser/features/RightSidebar/Tabs/TabLabels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import React from "react";
import {
BugPlay,
ExternalLink,
FolderOpen,
Monitor,
Globe,
Sparkles,
Expand Down Expand Up @@ -158,6 +159,14 @@ export const BrowserTabLabel: React.FC = () => (
</span>
);

/** Files tab label with folder icon */
export const FilesTabLabel: React.FC = () => (
<span className="inline-flex items-center gap-1">
<FolderOpen className="h-3 w-3 shrink-0" />
Files
</span>
);

/** Debug tab label with bug icon */
export const DebugTabLabel: React.FC = () => (
<span className="inline-flex items-center gap-1">
Expand Down
7 changes: 7 additions & 0 deletions src/browser/features/RightSidebar/Tabs/tabConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions src/browser/features/RightSidebar/Tabs/tabRegistry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -29,6 +30,7 @@ import {
BrowserTabLabel,
DebugTabLabel,
DesktopTabLabel,
FilesTabLabel,
GoalTabLabel,
InstructionsTabLabel,
OutputTabLabel,
Expand Down Expand Up @@ -165,6 +167,14 @@ const TAB_RENDERERS = {
</ErrorBoundary>
),
},
files: {
Label: FilesTabLabel,
renderPanel: (ctx) => (
<ErrorBoundary workspaceInfo="Files tab">
<FilesTab projectPath={ctx.projectPath} />
</ErrorBoundary>
),
},
browser: {
Label: BrowserTabLabel,
renderPanel: (ctx) => (
Expand Down
10 changes: 10 additions & 0 deletions src/common/constants/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -150,6 +151,15 @@ export const EXPERIMENTS: Record<ExperimentId, ExperimentDefinition> = {
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 {
Expand Down
22 changes: 22 additions & 0 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
12 changes: 12 additions & 0 deletions src/node/orpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
95 changes: 95 additions & 0 deletions src/node/services/projectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,83 @@ async function listDirectory(requestedPath: string): Promise<FileTreeNode> {
};
}

// 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<Array<{ name: string; path: string; isDirectory: boolean }>> {
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;
Expand Down Expand Up @@ -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<Result<{ normalizedPath: string }, string>> {
Expand Down