feat(KB-651): add changed files diff viewer to dashboard
- Add backend API endpoint /api/tasks/:id/diff for file diffs - Add useChangedFiles hook for fetching and managing diff state - Create ChangedFilesModal component with file list and diff viewer - Integrate modal with TaskCard via files changed badge click - Add CSS styling for changed files layout and diff display - Add keyboard navigation (Escape to close) - Include unit tests for ChangedFilesModal component
This commit is contained in:
@@ -35,7 +35,7 @@ interface BoardProps {
|
||||
* Called when the user clicks the "Subtask" button in the inline create card.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
|
||||
/** Project context for multi-project mode */
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
|
||||
@@ -74,14 +74,14 @@ export function ChangedFilesModal({ taskId, worktree, column, isOpen, onClose }:
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="file-browser-body">
|
||||
<aside className="file-browser-sidebar" style={{ flex: "0 0 30%" }}>
|
||||
<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-state">No files changed</div>
|
||||
<div className="file-browser-empty">No files changed</div>
|
||||
) : (
|
||||
<div className="file-browser-list" role="list" aria-label="Changed files list">
|
||||
{files.map((file) => {
|
||||
@@ -91,14 +91,13 @@ export function ChangedFilesModal({ taskId, worktree, column, isOpen, onClose }:
|
||||
key={`${file.oldPath ?? ""}:${file.path}`}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className={`file-browser-entry ${active ? "active" : ""}`}
|
||||
aria-label={file.path}
|
||||
className={`file-node file-node--file changed-files-entry ${active ? "active" : ""}`}
|
||||
onClick={() => setSelectedFile(file)}
|
||||
>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||||
{getStatusIcon(file.status)}
|
||||
<span>{file.path}</span>
|
||||
</span>
|
||||
<span className="badge">{getStatusLabel(file.status)}</span>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
@@ -106,9 +105,9 @@ export function ChangedFilesModal({ taskId, worktree, column, isOpen, onClose }:
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="file-browser-content" style={{ flex: "0 0 70%" }}>
|
||||
<section className="file-browser-content changed-files-content">
|
||||
{!loading && !error && files.length > 0 && !selectedFile ? (
|
||||
<div className="file-browser-empty-state">Select a file to view changes</div>
|
||||
<div className="file-browser-empty">Select a file to view changes</div>
|
||||
) : null}
|
||||
|
||||
{selectedFile ? (
|
||||
@@ -116,7 +115,7 @@ export function ChangedFilesModal({ taskId, worktree, column, isOpen, onClose }:
|
||||
<div className="file-browser-toolbar">
|
||||
<div className="file-browser-file-info">
|
||||
<strong>{selectedFile.path}</strong>
|
||||
<span className="badge">{getStatusLabel(selectedFile.status)}</span>
|
||||
<span className="detail-column-badge changed-files-badge">{getStatusLabel(selectedFile.status)}</span>
|
||||
{selectedFile.oldPath ? <span>Renamed from {selectedFile.oldPath}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ interface ColumnProps {
|
||||
* Called when the user clicks the "Subtask" button in the inline create card.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
|
||||
}
|
||||
|
||||
function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask }: ColumnProps) {
|
||||
|
||||
@@ -43,7 +43,7 @@ interface TaskCardProps {
|
||||
) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
onOpenFilesForTask?: (taskId: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
|
||||
}
|
||||
|
||||
function areTaskBadgeInfosEqual(
|
||||
@@ -694,7 +694,7 @@ function TaskCardComponent({
|
||||
className="card-session-files"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenFilesForTask?.(task.id);
|
||||
onOpenFilesForTask?.(task.id, task.worktree, task.column);
|
||||
}}
|
||||
disabled={!onOpenFilesForTask}
|
||||
>
|
||||
|
||||
@@ -15,7 +15,7 @@ interface WorktreeGroupProps {
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
onOpenFilesForTask?: (taskId: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
|
||||
}
|
||||
|
||||
function WorktreeGroupComponent({
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("ChangedFilesModal", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText("Changed Files — KB-651")).toBeInTheDocument();
|
||||
expect(screen.getByText("src/a.ts")).toBeInTheDocument();
|
||||
expect(screen.getByRole("listitem", { name: "src/a.ts" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Diff for src/a.ts")).toBeInTheDocument();
|
||||
expect(screen.getByText(/\+hello/)).toBeInTheDocument();
|
||||
});
|
||||
@@ -57,7 +57,7 @@ describe("ChangedFilesModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /src\/b.ts/i }));
|
||||
fireEvent.click(screen.getByRole("listitem", { name: /src\/b.ts/i }));
|
||||
|
||||
expect(mockSetSelectedFile).toHaveBeenCalledWith({ path: "src/b.ts", status: "added", diff: "diff --git a/src/b.ts b/src/b.ts" });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user