Files
fusion/packages/dashboard/app/components/ChangedFilesModal.tsx
gsxdsm fa096cf518 feat(FN-683): add diff syntax highlighting with background colors
- Add highlightDiff utility React component for diff rendering
- Add CSS styles for diff-add (green), diff-del (red), diff-hunk (blue) line highlighting
- Integrate highlightDiff in TaskChangesTab for inline diffs
- Integrate highlightDiff in ChangedFilesModal for file diffs
- Add unit tests for highlightDiff utility
2026-04-02 09:58:04 -07:00

137 lines
5.2 KiB
TypeScript

import { useEffect, useMemo } from "react";
import { FileEdit, FileMinus, FilePlus, FileSymlink, FolderGit2, X } from "lucide-react";
import { useChangedFiles } from "../hooks/useChangedFiles";
import { highlightDiff } from "../utils/highlightDiff";
import type { TaskFileDiff } from "../api";
interface ChangedFilesModalProps {
taskId: string;
worktree: string | undefined;
column: string;
isOpen: boolean;
onClose: () => void;
}
function getStatusLabel(status: TaskFileDiff["status"]): string {
switch (status) {
case "added":
return "A";
case "deleted":
return "D";
case "renamed":
return "R";
default:
return "M";
}
}
function getStatusIcon(status: TaskFileDiff["status"]) {
switch (status) {
case "added":
return <FilePlus size={16} />;
case "deleted":
return <FileMinus size={16} />;
case "renamed":
return <FileSymlink size={16} />;
default:
return <FileEdit size={16} />;
}
}
function getDiffStat(diff: string): string {
const lines = diff.split("\n");
const statLines = lines.filter((line) => line.startsWith("diff --git") || line.startsWith("index ") || line.startsWith("--- ") || line.startsWith("+++ "));
return statLines.join("\n").trim();
}
export function ChangedFilesModal({ taskId, worktree, column, isOpen, onClose }: ChangedFilesModalProps) {
const { files, loading, error, selectedFile, setSelectedFile } = useChangedFiles(taskId, worktree, column);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
const selectedStat = useMemo(() => (selectedFile ? getDiffStat(selectedFile.diff) : ""), [selectedFile]);
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={onClose}>
<div className="modal file-browser-modal changed-files-modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-header file-browser-modal-header">
<div className="file-browser-header-title">
<FolderGit2 size={18} />
<span>Changed Files {taskId}</span>
</div>
<button className="modal-close" onClick={onClose} aria-label="Close changed files viewer">
<X size={20} />
</button>
</div>
<div className="file-browser-body changed-files-layout">
<aside className="file-browser-sidebar changed-files-sidebar">
{loading ? (
<div className="gm-diff-loading">Loading changed files</div>
) : error ? (
<div className="gm-diff-error">{error}</div>
) : files.length === 0 ? (
<div className="file-browser-empty">No files changed</div>
) : (
<div className="file-browser-list" role="list" aria-label="Changed files list">
{files.map((file) => {
const active = selectedFile?.path === file.path && selectedFile?.oldPath === file.oldPath;
return (
<button
key={`${file.oldPath ?? ""}:${file.path}`}
type="button"
role="listitem"
aria-label={file.path}
className={`file-node file-node--file changed-files-entry ${active ? "active" : ""}`}
onClick={() => setSelectedFile(file)}
>
<span className="file-node-icon">{getStatusIcon(file.status)}</span>
<span className="file-node-name">{file.path}</span>
<span className="detail-column-badge changed-files-badge">{getStatusLabel(file.status)}</span>
</button>
);
})}
</div>
)}
</aside>
<section className="file-browser-content changed-files-content">
{!loading && !error && files.length > 0 && !selectedFile ? (
<div className="file-browser-empty">Select a file to view changes</div>
) : null}
{selectedFile ? (
<div className="gm-diff-section changed-files-diff-section" aria-label={`Diff for ${selectedFile.path}`}>
<div className="file-browser-toolbar">
<div className="file-browser-file-info">
<strong>{selectedFile.path}</strong>
<span className="detail-column-badge changed-files-badge">{getStatusLabel(selectedFile.status)}</span>
{selectedFile.oldPath ? <span>Renamed from {selectedFile.oldPath}</span> : null}
</div>
</div>
<div className="gm-diff-viewer">
{selectedStat ? <pre className="gm-diff-stat">{selectedStat}</pre> : null}
<pre className="gm-diff-patch">
<code>{highlightDiff(selectedFile.diff || "No diff available")}</code>
</pre>
</div>
</div>
) : null}
</section>
</div>
</div>
</div>
);
}