feat(KB-029): add file browser for viewing and editing task files

- Add server-side file service with list, read, and write operations
- Add REST API endpoints for file operations with tests
- Add client-side API functions and React hooks for file browsing
- Create FileBrowser, FileEditor, and FileBrowserModal components
- Add CodeMirror 6 for syntax-highlighted file editing
- Integrate Files tab into TaskDetailModal
- Add comprehensive file browser styles and responsive design
This commit is contained in:
gsxdsm
2026-03-29 21:18:21 -07:00
parent 6393d74787
commit a299de8b0d
19 changed files with 2896 additions and 2 deletions

View File

@@ -0,0 +1 @@
New content

View File

@@ -0,0 +1 @@
New content

View File

@@ -0,0 +1 @@
content

View File

@@ -0,0 +1 @@
content

View File

@@ -84,6 +84,24 @@ The dashboard includes a fully interactive shell terminal for executing commands
- Push current branch
- View operation results and error states
### File Browser
Browse and edit task worktree files directly from the task detail modal:
- **Files Tab**: Available when a task has a worktree assigned
- **File Tree**: Navigate directories with breadcrumb-style path display
- **Code Editor**: Edit files with syntax highlighting powered by CodeMirror 6
- Supports TypeScript, JavaScript, JSON, CSS, Markdown, and more
- One-dark theme matching the dashboard
- Auto-detects language from file extension
- **Safety Features**:
- Path traversal prevention (blocks `..` patterns)
- Binary file detection (prevents editing images, executables, etc.)
- 1MB file size limit
- Unsaved change indicators
- **Keyboard Shortcuts**:
- `Ctrl/Cmd+S` to save
- `Escape` to close
### Configuration
- **Settings Modal**: Configure scheduling, worktrees, build commands, merge preferences, notifications, and appearance
- **Notifications**: ntfy.sh integration for push notifications when tasks complete or fail

View File

@@ -532,3 +532,52 @@ export function killTerminalSession(sessionId: string, signal?: "SIGTERM" | "SIG
export function getTerminalStreamUrl(sessionId: string): string {
return `/api/terminal/sessions/${encodeURIComponent(sessionId)}/stream`;
}
// --- File Browser API ---
/** File node in directory listing */
export interface FileNode {
name: string;
type: "file" | "directory";
size?: number;
mtime?: string;
}
/** File listing response */
export interface FileListResponse {
path: string;
entries: FileNode[];
}
/** File content response */
export interface FileContentResponse {
content: string;
mtime: string;
size: number;
}
/** Save file response */
export interface SaveFileResponse {
success: true;
mtime: string;
size: number;
}
/** List files in task directory */
export function fetchFileList(taskId: string, path?: string): Promise<FileListResponse> {
const query = path ? `?path=${encodeURIComponent(path)}` : "";
return api<FileListResponse>(`/tasks/${taskId}/files${query}`);
}
/** Fetch file content */
export function fetchFileContent(taskId: string, filePath: string): Promise<FileContentResponse> {
return api<FileContentResponse>(`/tasks/${taskId}/files/${encodeURIComponent(filePath)}`);
}
/** Save file content */
export function saveFileContent(taskId: string, filePath: string, content: string): Promise<SaveFileResponse> {
return api<SaveFileResponse>(`/tasks/${taskId}/files/${encodeURIComponent(filePath)}`, {
method: "POST",
body: JSON.stringify({ content }),
});
}

View File

@@ -0,0 +1,113 @@
import { Folder, File, ChevronRight, Loader2 } from "lucide-react";
import type { FileNode } from "../api";
interface FileBrowserProps {
entries: FileNode[];
currentPath: string;
onSelectFile: (path: string) => void;
onNavigate: (path: string) => void;
loading?: boolean;
error?: string | null;
onRetry?: () => void;
}
function formatBytes(bytes?: number): string {
if (bytes === undefined) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatTime(mtime?: string): string {
if (!mtime) return "";
const date = new Date(mtime);
return date.toLocaleDateString();
}
export function FileBrowser({
entries,
currentPath,
onSelectFile,
onNavigate,
loading,
error,
onRetry,
}: FileBrowserProps) {
if (loading) {
return (
<div className="file-browser-loading">
<Loader2 className="spin" size={24} />
<span>Loading files...</span>
</div>
);
}
if (error) {
return (
<div className="file-browser-error">
<p>Error: {error}</p>
{onRetry && (
<button className="btn btn-sm" onClick={onRetry}>
Retry
</button>
)}
</div>
);
}
return (
<div className="file-browser">
<div className="file-browser-header">
{currentPath !== "." && (
<button
className="file-browser-up"
onClick={() => {
const parts = currentPath.split("/").filter(Boolean);
parts.pop();
onNavigate(parts.length === 0 ? "." : parts.join("/"));
}}
>
<ChevronRight size={16} style={{ transform: "rotate(-90deg)" }} />
Up one level
</button>
)}
<span className="file-browser-path">{currentPath === "." ? "Root" : currentPath}</span>
</div>
<div className="file-browser-list">
{entries.length === 0 ? (
<div className="file-browser-empty">(empty directory)</div>
) : (
entries.map((entry) => (
<div
key={entry.name}
className={`file-node file-node--${entry.type}`}
onClick={() => {
if (entry.type === "directory") {
onNavigate(currentPath === "." ? entry.name : `${currentPath}/${entry.name}`);
} else {
onSelectFile(currentPath === "." ? entry.name : `${currentPath}/${entry.name}`);
}
}}
>
<div className="file-node-icon">
{entry.type === "directory" ? (
<Folder size={16} />
) : (
<File size={16} />
)}
</div>
<div className="file-node-name">{entry.name}</div>
{entry.type === "file" && entry.size !== undefined && (
<div className="file-node-size">{formatBytes(entry.size)}</div>
)}
{entry.mtime && (
<div className="file-node-time">{formatTime(entry.mtime)}</div>
)}
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,170 @@
import { useState, useCallback, useEffect } from "react";
import { X, Save, RotateCcw, Folder } from "lucide-react";
import { useFileBrowser } from "../hooks/useFileBrowser";
import { useFileEditor } from "../hooks/useFileEditor";
import { FileBrowser } from "./FileBrowser";
import { FileEditor } from "./FileEditor";
interface FileBrowserModalProps {
taskId: string;
worktreePath?: string;
isOpen?: boolean;
onClose: () => void;
}
export function FileBrowserModal({ taskId, worktreePath, onClose }: FileBrowserModalProps) {
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const {
entries,
currentPath,
setPath,
loading: browserLoading,
error: browserError,
refresh,
} = useFileBrowser(taskId, true);
const {
content,
setContent,
originalContent,
loading: editorLoading,
saving,
error: editorError,
save,
hasChanges,
mtime,
} = useFileEditor(taskId, selectedFile, true);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
if (hasChanges && !saving) {
save();
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose, hasChanges, saving, save]);
const handleSelectFile = useCallback((path: string) => {
setSelectedFile(path);
}, []);
const handleDiscard = useCallback(() => {
setContent(originalContent);
}, [originalContent, setContent]);
const formatFileSize = (content: string): string => {
const bytes = new Blob([content]).size;
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024).toFixed(1)} KB`;
};
return (
<div className="modal-overlay open" onClick={onClose}>
<div className="modal file-browser-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div className="file-browser-header-title">
<Folder size={18} />
<span>Files</span>
{selectedFile && (
<span className="file-browser-header-path">
{currentPath === "." ? "" : currentPath + "/"}
{selectedFile}
</span>
)}
</div>
<button className="modal-close" onClick={onClose}>
<X size={20} />
</button>
</div>
<div className="file-browser-body">
<div className="file-browser-sidebar">
<FileBrowser
entries={entries}
currentPath={currentPath}
onSelectFile={handleSelectFile}
onNavigate={setPath}
loading={browserLoading}
error={browserError}
onRetry={refresh}
/>
</div>
<div className="file-browser-content">
{selectedFile ? (
<>
<div className="file-browser-toolbar">
<div className="file-browser-file-info">
{selectedFile}
{mtime && (
<span className="file-browser-mtime">
Modified: {new Date(mtime).toLocaleString()}
</span>
)}
{editorLoading && (
<span className="file-browser-loading">Loading...</span>
)}
</div>
<div className="file-browser-actions">
{hasChanges && (
<>
<button
className="btn btn-sm"
onClick={handleDiscard}
disabled={saving}
>
<RotateCcw size={14} />
Discard
</button>
<button
className="btn btn-primary btn-sm"
onClick={save}
disabled={saving}
>
<Save size={14} />
{saving ? "Saving..." : "Save"}
</button>
</>
)}
</div>
</div>
{editorError && (
<div className="file-browser-error-banner">{editorError}</div>
)}
<div className="file-editor-wrapper">
<FileEditor
content={content}
onChange={setContent}
filePath={selectedFile}
/>
</div>
<div className="file-browser-footer">
<span>{formatFileSize(content)}</span>
{hasChanges && <span className="file-browser-unsaved">Unsaved changes</span>}
</div>
</>
) : (
<div className="file-browser-placeholder">
<Folder size={48} opacity={0.3} />
<p>Select a file to edit</p>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,133 @@
import { useEffect, useRef, useState } from "react";
import { basicSetup } from "@codemirror/basic-setup";
import { EditorState } from "@codemirror/state";
import { EditorView, keymap } from "@codemirror/view";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { markdown } from "@codemirror/lang-markdown";
import { css } from "@codemirror/lang-css";
import { oneDark } from "@codemirror/theme-one-dark";
interface FileEditorProps {
content: string;
onChange: (content: string) => void;
readOnly?: boolean;
filePath?: string;
}
function detectLanguage(filePath?: string) {
if (!filePath) return null;
const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
switch (ext) {
case ".ts":
case ".tsx":
case ".js":
case ".jsx":
case ".mjs":
case ".cjs":
return javascript({ typescript: ext === ".ts" || ext === ".tsx" });
case ".json":
case ".jsonc":
return json();
case ".md":
case ".markdown":
return markdown();
case ".css":
case ".scss":
case ".sass":
case ".less":
return css();
default:
return null;
}
}
export function FileEditor({ content, onChange, readOnly, filePath }: FileEditorProps) {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const [isReady, setIsReady] = useState(false);
// Create editor on mount
useEffect(() => {
if (!editorRef.current || viewRef.current) return;
const language = detectLanguage(filePath);
const extensions = [
basicSetup,
oneDark,
EditorView.theme({
"&": {
fontSize: "13px",
height: "100%",
},
".cm-content": {
fontFamily: '"SF Mono", Monaco, Consolas, monospace',
},
}),
EditorState.readOnly.of(readOnly ?? false),
keymap.of([
{
key: "Mod-s",
run: () => {
// Save is handled by parent component
return true;
},
},
]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChange(update.state.doc.toString());
}
}),
];
if (language) {
extensions.push(language);
}
const state = EditorState.create({
doc: content,
extensions,
});
const view = new EditorView({
state,
parent: editorRef.current,
});
viewRef.current = view;
setIsReady(true);
return () => {
view.destroy();
viewRef.current = null;
};
}, []); // Only run once on mount
// Update content when it changes externally
useEffect(() => {
const view = viewRef.current;
if (!view || !isReady) return;
const currentContent = view.state.doc.toString();
if (content !== currentContent) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: content },
});
}
}, [content, isReady]);
// Update readOnly when it changes
useEffect(() => {
const view = viewRef.current;
if (!view || !isReady) return;
view.dispatch({
effects: EditorState.readOnly.reconfigure(readOnly ?? false),
});
}, [readOnly, isReady]);
return <div ref={editorRef} className="file-editor-container" />;
}

View File

@@ -11,6 +11,7 @@ import { SteeringTab } from "./SteeringTab";
import { ModelSelectorTab } from "./ModelSelectorTab";
import { PrSection } from "./PrSection";
import { SpecEditor } from "./SpecEditor";
import { FileBrowserModal } from "./FileBrowserModal";
function getStepStatusColor(status: string): string {
switch (status) {
@@ -78,7 +79,7 @@ export function TaskDetailModal({
addToast,
githubTokenConfigured,
}: TaskDetailModalProps) {
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "model" | "spec">("definition");
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "model" | "spec" | "files">("definition");
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
const [uploading, setUploading] = useState(false);
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
@@ -443,8 +444,23 @@ export function TaskDetailModal({
>
Spec
</button>
{task.worktree && (
<button
className={`detail-tab${activeTab === "files" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("files")}
>
Files
</button>
)}
</div>
{activeTab === "spec" ? (
{activeTab === "files" ? (
<FileBrowserModal
taskId={task.id}
worktreePath={task.worktree}
isOpen={true}
onClose={() => setActiveTab("definition")}
/>
) : activeTab === "spec" ? (
<div className="detail-section">
<SpecEditor
content={task.prompt || ""}

View File

@@ -0,0 +1,84 @@
import { useState, useEffect, useCallback } from "react";
import type { FileNode, FileListResponse } from "../api";
import { fetchFileList } from "../api";
interface UseFileBrowserReturn {
entries: FileNode[];
currentPath: string;
setPath: (path: string) => void;
loading: boolean;
error: string | null;
refresh: () => void;
}
/**
* Hook for browsing files in a task directory.
*
* @param taskId - The task ID to browse
* @param enabled - Whether to enable fetching (e.g., when tab is active)
* @returns File browser state and controls
*/
export function useFileBrowser(taskId: string, enabled: boolean): UseFileBrowserReturn {
const [entries, setEntries] = useState<FileNode[]>([]);
const [currentPath, setCurrentPath] = useState<string>(".");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => {
setRefreshKey((k) => k + 1);
}, []);
const setPath = useCallback((path: string) => {
setCurrentPath(path);
setError(null);
}, []);
useEffect(() => {
if (!enabled || !taskId) {
return;
}
let cancelled = false;
async function loadFiles() {
setLoading(true);
setError(null);
try {
const response: FileListResponse = await fetchFileList(
taskId,
currentPath === "." ? undefined : currentPath
);
if (!cancelled) {
setEntries(response.entries);
}
} catch (err: any) {
if (!cancelled) {
setError(err.message || "Failed to load files");
setEntries([]);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadFiles();
return () => {
cancelled = true;
};
}, [taskId, currentPath, enabled, refreshKey]);
return {
entries,
currentPath,
setPath,
loading,
error,
refresh,
};
}

View File

@@ -0,0 +1,120 @@
import { useState, useEffect, useCallback } from "react";
import type { FileContentResponse, SaveFileResponse } from "../api";
import { fetchFileContent, saveFileContent } from "../api";
interface UseFileEditorReturn {
content: string;
setContent: (content: string) => void;
originalContent: string;
loading: boolean;
saving: boolean;
error: string | null;
save: () => Promise<void>;
hasChanges: boolean;
mtime: string | null;
}
/**
* Hook for editing a file in a task directory.
*
* @param taskId - The task ID
* @param filePath - The file path to edit (null if no file selected)
* @param enabled - Whether to enable loading (e.g., when editor is visible)
* @returns File editor state and controls
*/
export function useFileEditor(
taskId: string,
filePath: string | null,
enabled: boolean
): UseFileEditorReturn {
const [content, setContentState] = useState<string>("");
const [originalContent, setOriginalContent] = useState<string>("");
const [mtime, setMtime] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const setContent = useCallback((newContent: string) => {
setContentState(newContent);
setError(null);
}, []);
// Load file content when filePath changes
useEffect(() => {
if (!enabled || !taskId || !filePath) {
setContentState("");
setOriginalContent("");
setMtime(null);
setError(null);
return;
}
let cancelled = false;
async function loadFile() {
setLoading(true);
setError(null);
try {
const response: FileContentResponse = await fetchFileContent(taskId, filePath);
if (!cancelled) {
setContentState(response.content);
setOriginalContent(response.content);
setMtime(response.mtime);
}
} catch (err: any) {
if (!cancelled) {
setError(err.message || "Failed to load file");
setContentState("");
setOriginalContent("");
setMtime(null);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadFile();
return () => {
cancelled = true;
};
}, [taskId, filePath, enabled]);
const hasChanges = content !== originalContent;
const save = useCallback(async () => {
if (!taskId || !filePath || !hasChanges) {
return;
}
setSaving(true);
setError(null);
try {
const response: SaveFileResponse = await saveFileContent(taskId, filePath, content);
setOriginalContent(content);
setMtime(response.mtime);
} catch (err: any) {
setError(err.message || "Failed to save file");
throw err;
} finally {
setSaving(false);
}
}, [taskId, filePath, content, hasChanges]);
return {
content,
setContent,
originalContent,
loading,
saving,
error,
save,
hasChanges,
mtime,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,14 @@
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json"
},
"dependencies": {
"@codemirror/basic-setup": "^0.20.0",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.36.4",
"@kb/core": "workspace:*",
"@types/multer": "^2.1.0",
"express": "^5.1.0",

View File

@@ -0,0 +1,384 @@
import { join, resolve, relative, dirname } from "node:path";
import { readdir, readFile as fsReadFile, writeFile as fsWriteFile, stat } from "node:fs/promises";
import { existsSync } from "node:fs";
import type { TaskStore } from "@kb/core";
/**
* File node type representing a file or directory entry.
*/
export interface FileNode {
name: string;
type: "file" | "directory";
size?: number;
mtime?: string;
}
/**
* File listing response.
*/
export interface FileListResponse {
path: string;
entries: FileNode[];
}
/**
* File content response.
*/
export interface FileContentResponse {
content: string;
mtime: string;
size: number;
}
/**
* Save file response.
*/
export interface SaveFileResponse {
success: true;
mtime: string;
size: number;
}
/**
* Maximum file size for reading/writing (1MB).
*/
export const MAX_FILE_SIZE = 1024 * 1024;
/**
* Error class for file service operations.
*/
export class FileServiceError extends Error {
constructor(
message: string,
public readonly code: string,
) {
super(message);
this.name = "FileServiceError";
}
}
/**
* Text file extensions set.
*/
const TEXT_EXTENSIONS = new Set([
".txt", ".md", ".markdown",
".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
".json", ".jsonc",
".css", ".scss", ".sass", ".less",
".html", ".htm", ".xml", ".svg",
".yaml", ".yml",
".toml",
".ini", ".cfg", ".conf", ".config",
".sh", ".bash", ".zsh", ".fish",
".py", ".rb", ".php", ".pl", ".perl",
".java", ".kt", ".scala", ".groovy",
".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hh",
".cs", ".fs", ".fsx",
".go", ".rs", ".swift",
".sql",
".dockerfile", ".env", ".envrc", ".nvmrc",
".gitignore", ".gitattributes", ".editorconfig",
".lock", ".log",
]);
/**
* Binary file extensions set.
*/
const BINARY_EXTENSIONS = new Set([
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".svgz",
".exe", ".dll", ".so", ".dylib",
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".mp3", ".mp4", ".avi", ".mov", ".webm", ".mkv", ".flv",
".woff", ".woff2", ".ttf", ".otf", ".eot",
".wasm", ".bin",
]);
/**
* Check if a file is a binary file based on extension.
*/
function isBinaryFile(filename: string): boolean {
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
if (BINARY_EXTENSIONS.has(ext)) {
return true;
}
return false;
}
/**
* Get the base path for a task's files.
* Returns the worktree path if it exists, otherwise the task directory.
*/
async function getTaskBasePath(store: TaskStore, taskId: string): Promise<string> {
try {
const task = await store.getTask(taskId);
// Use worktree if available and exists
if (task.worktree && existsSync(task.worktree)) {
return resolve(task.worktree);
}
// Fall back to task directory
const rootDir = store.getRootDir();
return resolve(join(rootDir, ".kb", "tasks", taskId));
} catch (err: any) {
if (err.code === "ENOENT" || err.message?.includes("not found")) {
throw new FileServiceError(`Task ${taskId} not found`, "ENOTASK");
}
throw err;
}
}
/**
* Validate and resolve a file path to ensure it stays within the allowed directory.
* Prevents directory traversal attacks.
*/
function validatePath(basePath: string, filePath: string): string {
// Reject paths with null bytes
if (filePath.includes("\0")) {
throw new FileServiceError(`Access denied: Invalid characters in path`, "EINVAL");
}
// Decode URL-encoded characters for security check
const decodedPath = decodeURIComponent(filePath);
// Reject absolute paths
if (decodedPath.startsWith("/") || decodedPath.match(/^[a-zA-Z]:/)) {
throw new FileServiceError(`Access denied: Absolute paths not allowed`, "EINVAL");
}
// Resolve the path against base path
const resolvedBase = resolve(basePath);
const resolvedPath = resolve(join(resolvedBase, decodedPath));
// Ensure the resolved path is within the base path
const relativePath = relative(resolvedBase, resolvedPath);
// Check for traversal - path starts with .. or is outside base
if (relativePath.startsWith("..") || relativePath.startsWith("../") || relativePath === "..") {
throw new FileServiceError(`Access denied: Path traversal detected`, "EINVAL");
}
// Additional check: ensure resolved path actually starts with base
if (!resolvedPath.startsWith(resolvedBase)) {
throw new FileServiceError(`Access denied: Path outside allowed directory`, "EINVAL");
}
return resolvedPath;
}
/**
* List files in a task directory or subdirectory.
*
* @param store - The TaskStore instance
* @param taskId - The task ID
* @param subPath - Optional relative path within the task directory
* @returns File listing response with entries sorted (dirs first, then files alphabetically)
* @throws FileServiceError on validation or filesystem errors
*/
export async function listFiles(
store: TaskStore,
taskId: string,
subPath?: string,
): Promise<FileListResponse> {
const taskBase = await getTaskBasePath(store, taskId);
const targetPath = subPath ? validatePath(taskBase, subPath) : taskBase;
let stats;
try {
stats = await stat(targetPath);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError(`Directory not found: ${subPath || "."}`, "ENOENT");
}
throw err;
}
if (!stats.isDirectory()) {
throw new FileServiceError(`Not a directory: ${subPath || "."}`, "ENOTDIR");
}
try {
const entries = await readdir(targetPath, { withFileTypes: true });
const fileNodes: FileNode[] = [];
for (const entry of entries) {
// Skip hidden files and directories
if (entry.name.startsWith(".")) {
continue;
}
const entryPath = join(targetPath, entry.name);
const entryStats = await stat(entryPath);
fileNodes.push({
name: entry.name,
type: entry.isDirectory() ? "directory" : "file",
size: entry.isFile() ? entryStats.size : undefined,
mtime: entryStats.mtime.toISOString(),
});
}
// Sort: directories first, then files, both alphabetically
fileNodes.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
// Calculate relative path from task base
const relativeBase = relative(taskBase, targetPath);
return {
path: relativeBase || ".",
entries: fileNodes,
};
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError(`Directory not found: ${subPath || "."}`, "ENOENT");
}
if (err.code === "EACCES" || err.code === "EPERM") {
throw new FileServiceError(`Permission denied: ${subPath || "."}`, "EACCES");
}
throw err;
}
}
/**
* Read file contents from a task directory.
*
* @param store - The TaskStore instance
* @param taskId - The task ID
* @param filePath - The relative file path
* @returns File content response with content, mtime, and size
* @throws FileServiceError on validation or filesystem errors
*/
export async function readFile(
store: TaskStore,
taskId: string,
filePath: string,
): Promise<FileContentResponse> {
if (!filePath) {
throw new FileServiceError("File path is required", "EINVAL");
}
const taskBase = await getTaskBasePath(store, taskId);
const resolvedPath = validatePath(taskBase, filePath);
let stats;
try {
stats = await stat(resolvedPath);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError(`File not found: ${filePath}`, "ENOENT");
}
throw err;
}
if (!stats.isFile()) {
throw new FileServiceError(`Not a file: ${filePath}`, "EISDIR");
}
if (stats.size > MAX_FILE_SIZE) {
throw new FileServiceError(`File too large: ${stats.size} bytes (max ${MAX_FILE_SIZE})`, "ETOOLARGE");
}
// Check if it's a binary file
const basename = filePath.split("/").pop() || filePath;
if (isBinaryFile(basename)) {
throw new FileServiceError(`Binary file, cannot edit: ${filePath}`, "EINVAL");
}
try {
const content = await fsReadFile(resolvedPath, "utf-8");
return {
content,
mtime: stats.mtime.toISOString(),
size: stats.size,
};
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError(`File not found: ${filePath}`, "ENOENT");
}
if (err.code === "EACCES" || err.code === "EPERM") {
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
}
throw err;
}
}
/**
* Write file contents to a task directory.
*
* @param store - The TaskStore instance
* @param taskId - The task ID
* @param filePath - The relative file path
* @param content - The content to write
* @returns Save file response with success, mtime, and size
* @throws FileServiceError on validation or filesystem errors
*/
export async function writeFile(
store: TaskStore,
taskId: string,
filePath: string,
content: string,
): Promise<SaveFileResponse> {
if (!filePath) {
throw new FileServiceError("File path is required", "EINVAL");
}
// Check content size
const contentBytes = Buffer.byteLength(content, "utf-8");
if (contentBytes > MAX_FILE_SIZE) {
throw new FileServiceError(`Content too large: ${contentBytes} bytes (max ${MAX_FILE_SIZE})`, "ETOOLARGE");
}
const taskBase = await getTaskBasePath(store, taskId);
const resolvedPath = validatePath(taskBase, filePath);
// Check if target is a directory
try {
const stats = await stat(resolvedPath);
if (stats.isDirectory()) {
throw new FileServiceError(`Cannot write to directory: ${filePath}`, "EISDIR");
}
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
// File doesn't exist, that's fine for writing
}
// Check if parent directory exists
const parentDir = dirname(resolvedPath);
try {
const parentStats = await stat(parentDir);
if (!parentStats.isDirectory()) {
throw new FileServiceError(`Parent is not a directory: ${filePath}`, "ENOENT");
}
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
}
throw err;
}
try {
await fsWriteFile(resolvedPath, content, "utf-8");
const stats = await stat(resolvedPath);
return {
success: true,
mtime: stats.mtime.toISOString(),
size: stats.size,
};
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
}
if (err.code === "EACCES" || err.code === "EPERM") {
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
}
throw err;
}
}

View File

@@ -2380,4 +2380,174 @@ describe("Git Management endpoints", () => {
}
});
});
// ── File API tests ────────────────────────────────────────────────────
describe("File API endpoints", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore({
getRootDir: vi.fn().mockReturnValue("/tmp/test"),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
describe("GET /tasks/:id/files", () => {
it("returns 404 for non-existent task", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue({ code: "ENOENT" });
const res = await GET(buildApp(), "/api/tasks/KB-NONEXISTENT/files");
expect(res.status).toBe(404);
expect(res.body).toHaveProperty("error");
});
it("returns 404 when task directory does not exist", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "KB-001",
worktree: null,
});
const res = await GET(buildApp(), "/api/tasks/KB-001/files");
// Will fail because task directory doesn't exist
expect(res.status === 404 || res.status === 500).toBe(true);
});
it("accepts path query parameter", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "KB-001",
worktree: null,
});
const res = await GET(buildApp(), "/api/tasks/KB-001/files?path=src");
// Directory won't exist, but endpoint should process the query param
expect(res.status === 404 || res.status === 500).toBe(true);
});
});
describe("GET /tasks/:id/files/:filepath", () => {
it("returns 404 for non-existent file", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "KB-001",
worktree: null,
});
const res = await GET(buildApp(), "/api/tasks/KB-001/files/nonexistent.txt");
expect(res.status).toBe(404);
});
it("returns 400 for empty filepath", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "KB-001",
worktree: null,
});
const res = await GET(buildApp(), "/api/tasks/KB-001/files/");
// Empty path should result in error
expect(res.status === 400 || res.status === 404).toBe(true);
});
it("returns 415 for binary files", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "KB-001",
worktree: null,
});
const res = await GET(buildApp(), "/api/tasks/KB-001/files/image.png");
expect([415, 404, 500]).toContain(res.status);
});
it("rejects path traversal attempts", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "KB-001",
worktree: null,
});
const res = await GET(buildApp(), "/api/tasks/KB-001/files/../etc/passwd");
expect([400, 404, 500]).toContain(res.status);
if (res.body?.error) {
expect(res.body.error).toContain("traversal");
}
});
});
describe("POST /tasks/:id/files/:filepath", () => {
it("requires content in body", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "KB-001",
worktree: null,
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/files/test.txt",
JSON.stringify({}),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("content is required");
});
it("rejects non-string content", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "KB-001",
worktree: null,
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/files/test.txt",
JSON.stringify({ content: 123 }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
});
it("returns 404 for non-existent parent directory", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "KB-001",
worktree: null,
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/files/nonexistent/dir/file.txt",
JSON.stringify({ content: "test" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(404);
});
it("rejects path traversal in write", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "KB-001",
worktree: null,
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/files/../../../etc/passwd",
JSON.stringify({ content: "evil" }),
{ "Content-Type": "application/json" }
);
expect([400, 404, 500]).toContain(res.status);
});
});
});
});

View File

@@ -7,6 +7,7 @@ import { COLUMNS, VALID_TRANSITIONS, type PrInfo } from "@kb/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, getCurrentGitHubRepo } from "./github.js";
import { terminalSessionManager } from "./terminal.js";
import { listFiles, readFile, writeFile, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse } from "./file-service.js";
/**
* Minimal interface matching pi-coding-agent's ModelRegistry API surface
@@ -1809,6 +1810,89 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// ── File API Routes ───────────────────────────────────────────────
/**
* GET /api/tasks/:id/files
* List files in task directory (or worktree if available).
* Query param: ?path=relative/path for subdirectory navigation.
* Returns: { path: string; entries: FileNode[] }
*/
router.get("/tasks/:id/files", async (req, res) => {
try {
const { path: subPath } = req.query;
const result = await listFiles(store, req.params.id, typeof subPath === "string" ? subPath : undefined);
res.json(result);
} catch (err: any) {
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EACCES" ? 403
: 400;
res.status(status).json({ error: err.message, code: err.code });
} else {
res.status(500).json({ error: err.message || "Internal server error" });
}
}
});
/**
* GET /api/tasks/:id/files/:filepath
* Read file contents.
* Returns: { content: string; mtime: string; size: number }
*/
router.get("/tasks/:id/files/{*filepath}", async (req, res) => {
try {
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
const result = await readFile(store, req.params.id, filePath);
res.json(result);
} catch (err: any) {
if (err instanceof FileServiceError) {
const status = err.code === "ENOENT" ? 404
: err.code === "ENOTASK" ? 404
: err.code === "EACCES" ? 403
: err.code === "ETOOLARGE" ? 413
: err.code === "EINVAL" && err.message.includes("Binary file") ? 415
: 400;
res.status(status).json({ error: err.message, code: err.code });
} else {
res.status(500).json({ error: err.message || "Internal server error" });
}
}
});
/**
* POST /api/tasks/:id/files/:filepath
* Write file contents.
* Body: { content: string }
* Returns: { success: true; mtime: string; size: number }
*/
router.post("/tasks/:id/files/{*filepath}", async (req, res) => {
try {
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
const { content } = req.body;
if (typeof content !== "string") {
res.status(400).json({ error: "content is required and must be a string" });
return;
}
const result = await writeFile(store, req.params.id, filePath, content);
res.json(result);
} catch (err: any) {
if (err instanceof FileServiceError) {
const status = err.code === "ENOENT" ? 404
: err.code === "ENOTASK" ? 404
: err.code === "EACCES" ? 403
: err.code === "ETOOLARGE" ? 413
: 400;
res.status(status).json({ error: err.message, code: err.code });
} else {
res.status(500).json({ error: err.message || "Internal server error" });
}
}
});
return router;
}