Split app/styles.css from ~40k lines down to ~4.5k. Created 56 co-located
component CSS files in app/components/, each imported by its owning .tsx.
The remainder of styles.css holds genuinely global rules (design tokens,
.btn/.card/.modal/.form-input primitives, cross-component @media overrides).
- Lazy-load 13 heavy views (AgentsView, RoadmapsView, NodesView, etc.) via
React.lazy + Suspense; prefetch all chunks on idle so first navigation is
instant. Initial JS bundle: 1.58 MB → 1.16 MB (-26%). Initial CSS bundle:
635 kB → 471 kB (-26%); the rest splits into 13 per-view chunks.
- Add app/test/cssFixture.ts exposing loadAllAppCss() + loadAllAppCssBaseOnly()
so CSS regression tests load the full per-component bundle (mirroring Vite
source order). Migrate 30+ tests off direct readFileSync('../styles.css').
- Enable test.css: { include: [/.+/] } in vitest.config.ts so component CSS
imports actually inject styles in jsdom (fixes getComputedStyle assertions).
- Add ESLint rule (no-restricted-syntax) banning direct styles.css reads in
dashboard test files; points at loadAllAppCss() instead.
- Restore lost utility classes (.text-muted, .text-secondary, .text-dim,
.form-input) and rescue dropped chat tool-call rules into QuickChatFAB.css.
- Mobile fixes along the way: scroll containment for view containers
(min-height:0 + -webkit-overflow-scrolling), QuickChatFAB full-screen on
mobile (with safe-area-inset for iOS home bar), AgentsView single-row
header layout, ActivityLogModal close button on right, model-combobox
z-index above the mobile quick-chat panel.
- Bug fix: SkillsView toggle was display:none which hid the input from the
accessibility tree; replaced with the visually-hidden pattern so screen
readers + getByRole still find the checkbox.
- Bug fix: standalone Delete button in TaskDetailModal for triage-column
tasks (Actions dropdown is hidden in triage state, so previously no way
to delete a freshly-created task without status change first).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
import "./FileBrowser.css";
|
|
import { useState, useCallback, useEffect, useMemo } from "react";
|
|
import { X, Save, RotateCcw, Folder, FileType, ArrowLeft } from "lucide-react";
|
|
import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser";
|
|
import { useWorkspaceFileEditor } from "../hooks/useWorkspaceFileEditor";
|
|
import { useWorkspaces } from "../hooks/useWorkspaces";
|
|
import { downloadFileUrl } from "../api";
|
|
import { FileBrowser } from "./FileBrowser";
|
|
import { FileEditor } from "./FileEditor";
|
|
import { WorkspaceSelector } from "./WorkspaceSelector";
|
|
|
|
const MOBILE_BREAKPOINT = 768;
|
|
|
|
/**
|
|
* Image file extensions that should be rendered as image previews.
|
|
*/
|
|
const IMAGE_EXTENSIONS = new Set([
|
|
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".svgz",
|
|
]);
|
|
|
|
/**
|
|
* Binary file extensions that should be displayed as read-only.
|
|
*/
|
|
const BINARY_EXTENSIONS = new Set([
|
|
...IMAGE_EXTENSIONS,
|
|
".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",
|
|
]);
|
|
|
|
function isBinaryFile(filename: string): boolean {
|
|
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
|
|
return BINARY_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
function isImageFile(filename: string): boolean {
|
|
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
|
|
return IMAGE_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
interface FileBrowserModalProps {
|
|
isOpen?: boolean;
|
|
initialWorkspace?: string;
|
|
onClose: () => void;
|
|
onWorkspaceChange?: (workspace: string) => void;
|
|
projectId?: string;
|
|
}
|
|
|
|
/**
|
|
* Workspace-aware file browser modal used by the top-level dashboard Files button.
|
|
* Supports browsing the project root or any active task worktree from one shared UI.
|
|
*/
|
|
export function FileBrowserModal({
|
|
initialWorkspace = "project",
|
|
onClose,
|
|
onWorkspaceChange,
|
|
projectId,
|
|
}: FileBrowserModalProps) {
|
|
const { projectName, workspaces } = useWorkspaces(projectId);
|
|
const [currentWorkspace, setCurrentWorkspace] = useState(initialWorkspace);
|
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
const [isMobile, setIsMobile] = useState(false);
|
|
const [mobileView, setMobileView] = useState<"list" | "editor">("list");
|
|
|
|
const {
|
|
entries,
|
|
currentPath,
|
|
setPath,
|
|
loading: browserLoading,
|
|
error: browserError,
|
|
refresh,
|
|
} = useWorkspaceFileBrowser(currentWorkspace, true, projectId);
|
|
|
|
const {
|
|
content,
|
|
setContent,
|
|
originalContent,
|
|
loading: editorLoading,
|
|
saving,
|
|
error: editorError,
|
|
save,
|
|
hasChanges,
|
|
mtime,
|
|
} = useWorkspaceFileEditor(currentWorkspace, selectedFile, true, projectId);
|
|
|
|
useEffect(() => {
|
|
setCurrentWorkspace(initialWorkspace);
|
|
}, [initialWorkspace]);
|
|
|
|
useEffect(() => {
|
|
const checkMobile = () => {
|
|
setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT);
|
|
};
|
|
|
|
checkMobile();
|
|
window.addEventListener("resize", checkMobile);
|
|
return () => window.removeEventListener("resize", checkMobile);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!selectedFile) {
|
|
setMobileView("list");
|
|
}
|
|
}, [selectedFile]);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
onClose();
|
|
}
|
|
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
e.preventDefault();
|
|
if (hasChanges && !saving) {
|
|
void save();
|
|
}
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [onClose, hasChanges, saving, save]);
|
|
|
|
const handleSelectFile = useCallback((path: string) => {
|
|
setSelectedFile(path);
|
|
if (isMobile) {
|
|
setMobileView("editor");
|
|
}
|
|
}, [isMobile]);
|
|
|
|
const handleBackToList = useCallback(() => {
|
|
setMobileView("list");
|
|
}, []);
|
|
|
|
const handleDiscard = useCallback(() => {
|
|
setContent(originalContent);
|
|
}, [originalContent, setContent]);
|
|
|
|
const handleWorkspaceSelect = useCallback((workspace: string) => {
|
|
setCurrentWorkspace(workspace);
|
|
setSelectedFile(null);
|
|
setMobileView("list");
|
|
onWorkspaceChange?.(workspace);
|
|
}, [onWorkspaceChange]);
|
|
|
|
const workspaceLabel = useMemo(() => {
|
|
if (currentWorkspace === "project") {
|
|
return "Project";
|
|
}
|
|
|
|
return workspaces.find((workspace) => workspace.id === currentWorkspace)?.id ?? currentWorkspace;
|
|
}, [currentWorkspace, workspaces]);
|
|
|
|
const modalTitle = `Files — ${workspaceLabel}`;
|
|
|
|
// Compute image source URL when an image file is selected
|
|
const imageSrc = useMemo(() => {
|
|
if (!selectedFile || !isImageFile(selectedFile)) return null;
|
|
return downloadFileUrl(currentWorkspace, selectedFile, projectId);
|
|
}, [selectedFile, currentWorkspace, projectId]);
|
|
|
|
const formatFileSize = (value: string): string => {
|
|
const bytes = new Blob([value]).size;
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
};
|
|
|
|
return (
|
|
<div className="modal-overlay open" onClick={onClose} role="dialog" aria-modal="true">
|
|
<div className="modal file-browser-modal" onClick={(e) => e.stopPropagation()}>
|
|
<div className="modal-header file-browser-modal-header">
|
|
<div className="file-browser-header-title">
|
|
<Folder size={18} />
|
|
<span>{modalTitle}</span>
|
|
{selectedFile && (
|
|
<span className="file-browser-header-path">
|
|
{selectedFile}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="file-browser-header-actions">
|
|
<WorkspaceSelector
|
|
currentWorkspace={currentWorkspace}
|
|
projectName={projectName}
|
|
workspaces={workspaces}
|
|
onSelect={handleWorkspaceSelect}
|
|
/>
|
|
<button className="modal-close" onClick={onClose} aria-label="Close">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="file-browser-body">
|
|
<div className={`file-browser-sidebar ${isMobile ? "mobile" : ""} ${mobileView === "list" ? "active" : ""}`}>
|
|
<FileBrowser
|
|
entries={entries}
|
|
currentPath={currentPath}
|
|
onSelectFile={handleSelectFile}
|
|
onNavigate={setPath}
|
|
loading={browserLoading}
|
|
error={browserError}
|
|
onRetry={refresh}
|
|
workspace={currentWorkspace}
|
|
onRefresh={refresh}
|
|
projectId={projectId}
|
|
/>
|
|
</div>
|
|
|
|
<div className={`file-browser-content ${isMobile ? "mobile" : ""} ${mobileView === "editor" ? "active" : ""}`}>
|
|
{selectedFile ? (
|
|
<>
|
|
<div className="file-browser-toolbar">
|
|
<div className="file-browser-file-info">
|
|
{isMobile && mobileView === "editor" && (
|
|
<button
|
|
className="file-browser-back-button"
|
|
onClick={handleBackToList}
|
|
aria-label="Back to file list"
|
|
>
|
|
<ArrowLeft size={16} />
|
|
<span>Back</span>
|
|
</button>
|
|
)}
|
|
{selectedFile}
|
|
{isBinaryFile(selectedFile) && (
|
|
<span className="file-browser-binary-indicator">
|
|
<FileType size={12} />
|
|
Binary file — read only
|
|
</span>
|
|
)}
|
|
{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">
|
|
{!imageSrc && hasChanges && (
|
|
<>
|
|
<button
|
|
className="btn btn-sm"
|
|
onClick={handleDiscard}
|
|
disabled={saving}
|
|
>
|
|
<RotateCcw size={14} />
|
|
Discard
|
|
</button>
|
|
<button
|
|
className="btn btn-primary btn-sm"
|
|
onClick={() => void save()}
|
|
disabled={saving}
|
|
>
|
|
<Save size={14} />
|
|
{saving ? "Saving..." : "Save"}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{editorError && !imageSrc && (
|
|
<div className="file-browser-error-banner">{editorError}</div>
|
|
)}
|
|
|
|
{imageSrc ? (
|
|
<div className="file-browser-image-preview">
|
|
<img
|
|
src={imageSrc}
|
|
alt={selectedFile ?? ""}
|
|
className="file-browser-image"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="file-editor-wrapper">
|
|
<FileEditor
|
|
content={content}
|
|
onChange={setContent}
|
|
filePath={selectedFile}
|
|
readOnly={isBinaryFile(selectedFile)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{!imageSrc && (
|
|
<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>
|
|
);
|
|
}
|