-
Notifications
You must be signed in to change notification settings - Fork 935
feat(workspace): support workspace tab (#656) #657
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,298 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { IncomingMessage, ServerResponse } from 'http'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { readdir, readFile, stat } from 'fs/promises'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { join, extname, relative, resolve, normalize } from 'path'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { homedir } from 'os'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { HostApiContext } from '../context'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { sendJson, setCorsHeaders } from '../route-utils'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { listAgentsSnapshot } from '../../utils/agent-config'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| interface FileTreeNode { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| path: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type: 'file' | 'directory'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| children?: FileTreeNode[]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const TEXT_EXTENSIONS = new Set([ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '.md', '.txt', '.json', '.yaml', '.yml', '.toml', '.xml', '.csv', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '.js', '.ts', '.jsx', '.tsx', '.py', '.rb', '.go', '.rs', '.java', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '.c', '.cpp', '.h', '.hpp', '.cs', '.swift', '.kt', '.sh', '.bash', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '.zsh', '.fish', '.ps1', '.bat', '.cmd', '.html', '.htm', '.css', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '.scss', '.less', '.sql', '.graphql', '.proto', '.lua', '.r', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '.m', '.mm', '.pl', '.pm', '.php', '.vue', '.svelte', '.astro', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '.env', '.ini', '.cfg', '.conf', '.log', '.diff', '.patch', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '.dockerfile', '.gitignore', '.editorconfig', '.prettierrc', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '.eslintrc', '.babelrc', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const IMAGE_EXTENSIONS = new Set([ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB limit for text previews | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB limit for images | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function expandPath(p: string): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (p.startsWith('~')) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return p.replace('~', homedir()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return p; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Validate that a requested path is within the allowed workspace root. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Prevents path traversal attacks. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function isPathWithinRoot(root: string, requestedPath: string): boolean { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const resolvedRoot = resolve(root); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const resolvedPath = resolve(root, requestedPath); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return resolvedPath.startsWith(resolvedRoot); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+46
to
+50
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function buildFileTree( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dirPath: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rootPath: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| depth: number = 0, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| maxDepth: number = 10, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<FileTreeNode[]> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (depth >= maxDepth) return []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let entries; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| entries = await readdir(dirPath, { withFileTypes: true }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Sort: directories first, then alphabetical | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| entries.sort((a, b) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (a.isDirectory() && !b.isDirectory()) return -1; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!a.isDirectory() && b.isDirectory()) return 1; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return a.name.localeCompare(b.name); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const nodes: FileTreeNode[] = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const entry of entries) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Skip hidden files/dirs and common unneeded dirs | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (entry.name.startsWith('.') && entry.name !== '.env') continue; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (entry.name === 'node_modules' || entry.name === '__pycache__' || entry.name === '.git') continue; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const fullPath = join(dirPath, entry.name); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const relativePath = relative(rootPath, fullPath); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (entry.isDirectory()) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const children = await buildFileTree(fullPath, rootPath, depth + 1, maxDepth); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nodes.push({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: entry.name, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| path: relativePath, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type: 'directory', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| children, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nodes.push({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: entry.name, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| path: relativePath, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+83
to
+95
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (entry.isDirectory()) { | |
| const children = await buildFileTree(fullPath, rootPath, depth + 1, maxDepth); | |
| nodes.push({ | |
| name: entry.name, | |
| path: relativePath, | |
| type: 'directory', | |
| children, | |
| }); | |
| } else { | |
| nodes.push({ | |
| name: entry.name, | |
| path: relativePath, | |
| const apiPath = relativePath.replace(/\\/g, '/'); | |
| if (entry.isDirectory()) { | |
| const children = await buildFileTree(fullPath, rootPath, depth + 1, maxDepth); | |
| nodes.push({ | |
| name: entry.name, | |
| path: apiPath, | |
| type: 'directory', | |
| children, | |
| }); | |
| } else { | |
| nodes.push({ | |
| name: entry.name, | |
| path: apiPath, |
Copilot
AI
Mar 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing unit tests for the new workspace API routes. Other route handlers under electron/api/routes/* have vitest coverage (e.g. channels, usage, app routes), so adding tests for agent listing, tree building, file reading, and path traversal rejection would help prevent regressions.
Copilot
AI
Mar 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The file read path check uses stat(fullPath), which follows symlinks. A symlink inside the workspace that points outside the workspace root would bypass the traversal protection and allow reading arbitrary files. Consider rejecting symlinks via lstat (and/or resolving realpath for both root and file and re-validating containment).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This route duplicates
expandPatheven though there’s alreadyexpandPathinelectron/utils/paths.ts. Reusing the shared helper avoids divergence (e.g., future behavior changes) and reduces duplicate imports (homedir).