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,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 || ""}