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:
113
packages/dashboard/app/components/FileBrowser.tsx
Normal file
113
packages/dashboard/app/components/FileBrowser.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
packages/dashboard/app/components/FileBrowserModal.tsx
Normal file
170
packages/dashboard/app/components/FileBrowserModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
packages/dashboard/app/components/FileEditor.tsx
Normal file
133
packages/dashboard/app/components/FileEditor.tsx
Normal 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" />;
|
||||
}
|
||||
@@ -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 || ""}
|
||||
|
||||
Reference in New Issue
Block a user