mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-07 12:17:50 +00:00
feat(US-401): diff viewer component
- DiffService for git diff operations - Diff API routes (summary, file, full) - DiffViewer component with file tree - Unified diff display with line numbers - Syntax highlighting by file extension - Added/removed line counts - Tabs UI in task detail panel (Details, Git, Agent, Changes)
This commit is contained in:
parent
88bf96a77f
commit
6fbbce09ce
12 changed files with 985 additions and 162 deletions
1
.veritas-kanban/worktrees/task_20260126_z6KAvr
Submodule
1
.veritas-kanban/worktrees/task_20260126_z6KAvr
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 88bf96a77f887086d7d88a535ceb44deb512a469
|
||||
32
pnpm-lock.yaml
generated
32
pnpm-lock.yaml
generated
|
|
@ -105,6 +105,9 @@ importers:
|
|||
'@radix-ui/react-slot':
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4(@types/react@19.2.9)(react@19.2.3)
|
||||
'@radix-ui/react-tabs':
|
||||
specifier: ^1.1.13
|
||||
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-toast':
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
|
|
@ -970,6 +973,19 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-tabs@1.1.13':
|
||||
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-toast@1.2.15':
|
||||
resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==}
|
||||
peerDependencies:
|
||||
|
|
@ -3272,6 +3288,22 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 19.2.9
|
||||
|
||||
'@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.2.3)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.9)(react@19.2.3)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.9)(react@19.2.3)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.9
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.9)
|
||||
|
||||
'@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { createServer } from 'http';
|
|||
import { taskRoutes } from './routes/tasks.js';
|
||||
import { configRoutes } from './routes/config.js';
|
||||
import { agentRoutes, agentService } from './routes/agents.js';
|
||||
import { diffRoutes } from './routes/diff.js';
|
||||
import type { AgentOutput } from './services/agent-service.js';
|
||||
|
||||
const app = express();
|
||||
|
|
@ -23,6 +24,7 @@ app.get('/health', (_req, res) => {
|
|||
app.use('/api/tasks', taskRoutes);
|
||||
app.use('/api/config', configRoutes);
|
||||
app.use('/api/agents', agentRoutes);
|
||||
app.use('/api/diff', diffRoutes);
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
|
|
|||
44
server/src/routes/diff.ts
Normal file
44
server/src/routes/diff.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { Router, type Router as RouterType } from 'express';
|
||||
import { DiffService } from '../services/diff-service.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
const diffService = new DiffService();
|
||||
|
||||
// GET /api/diff/:taskId - Get diff summary for task
|
||||
router.get('/:taskId', async (req, res) => {
|
||||
try {
|
||||
const summary = await diffService.getDiffSummary(req.params.taskId);
|
||||
res.json(summary);
|
||||
} catch (error: any) {
|
||||
console.error('Error getting diff summary:', error);
|
||||
res.status(400).json({ error: error.message || 'Failed to get diff summary' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/diff/:taskId/file - Get diff for specific file
|
||||
router.get('/:taskId/file', async (req, res) => {
|
||||
try {
|
||||
const { path } = req.query;
|
||||
if (!path || typeof path !== 'string') {
|
||||
return res.status(400).json({ error: 'File path is required' });
|
||||
}
|
||||
const diff = await diffService.getFileDiff(req.params.taskId, path);
|
||||
res.json(diff);
|
||||
} catch (error: any) {
|
||||
console.error('Error getting file diff:', error);
|
||||
res.status(400).json({ error: error.message || 'Failed to get file diff' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/diff/:taskId/full - Get full diff for all files
|
||||
router.get('/:taskId/full', async (req, res) => {
|
||||
try {
|
||||
const diffs = await diffService.getFullDiff(req.params.taskId);
|
||||
res.json(diffs);
|
||||
} catch (error: any) {
|
||||
console.error('Error getting full diff:', error);
|
||||
res.status(400).json({ error: error.message || 'Failed to get full diff' });
|
||||
}
|
||||
});
|
||||
|
||||
export { router as diffRoutes };
|
||||
258
server/src/services/diff-service.ts
Normal file
258
server/src/services/diff-service.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { simpleGit } from 'simple-git';
|
||||
import { TaskService } from './task-service.js';
|
||||
import type { Task } from '@veritas-kanban/shared';
|
||||
|
||||
export interface FileChange {
|
||||
path: string;
|
||||
status: 'added' | 'modified' | 'deleted' | 'renamed';
|
||||
additions: number;
|
||||
deletions: number;
|
||||
oldPath?: string; // For renames
|
||||
}
|
||||
|
||||
export interface FileDiff {
|
||||
path: string;
|
||||
status: 'added' | 'modified' | 'deleted' | 'renamed';
|
||||
hunks: DiffHunk[];
|
||||
language: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
export interface DiffHunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
newLines: number;
|
||||
lines: DiffLine[];
|
||||
}
|
||||
|
||||
export interface DiffLine {
|
||||
type: 'context' | 'add' | 'delete';
|
||||
content: string;
|
||||
oldNumber?: number;
|
||||
newNumber?: number;
|
||||
}
|
||||
|
||||
export interface DiffSummary {
|
||||
files: FileChange[];
|
||||
totalAdditions: number;
|
||||
totalDeletions: number;
|
||||
totalFiles: number;
|
||||
}
|
||||
|
||||
export class DiffService {
|
||||
private taskService: TaskService;
|
||||
|
||||
constructor() {
|
||||
this.taskService = new TaskService();
|
||||
}
|
||||
|
||||
private expandPath(p: string): string {
|
||||
return p.replace(/^~/, process.env.HOME || '');
|
||||
}
|
||||
|
||||
private getLanguageFromPath(filePath: string): string {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
const langMap: Record<string, string> = {
|
||||
'ts': 'typescript',
|
||||
'tsx': 'tsx',
|
||||
'js': 'javascript',
|
||||
'jsx': 'jsx',
|
||||
'json': 'json',
|
||||
'md': 'markdown',
|
||||
'css': 'css',
|
||||
'scss': 'scss',
|
||||
'html': 'html',
|
||||
'py': 'python',
|
||||
'rs': 'rust',
|
||||
'go': 'go',
|
||||
'rb': 'ruby',
|
||||
'java': 'java',
|
||||
'sh': 'bash',
|
||||
'yaml': 'yaml',
|
||||
'yml': 'yaml',
|
||||
'toml': 'toml',
|
||||
'sql': 'sql',
|
||||
};
|
||||
return langMap[ext] || 'plaintext';
|
||||
}
|
||||
|
||||
private parseStatusCode(code: string): FileChange['status'] {
|
||||
switch (code[0]) {
|
||||
case 'A': return 'added';
|
||||
case 'M': return 'modified';
|
||||
case 'D': return 'deleted';
|
||||
case 'R': return 'renamed';
|
||||
default: return 'modified';
|
||||
}
|
||||
}
|
||||
|
||||
async getDiffSummary(taskId: string): Promise<DiffSummary> {
|
||||
const task = await this.taskService.getTask(taskId);
|
||||
if (!task?.git?.worktreePath) {
|
||||
throw new Error('Task does not have an active worktree');
|
||||
}
|
||||
|
||||
const worktreePath = this.expandPath(task.git.worktreePath);
|
||||
const git = simpleGit(worktreePath);
|
||||
|
||||
// Get diff against base branch
|
||||
const baseBranch = task.git.baseBranch || 'main';
|
||||
|
||||
// Get list of changed files with stats
|
||||
const diffStat = await git.diffSummary([baseBranch]);
|
||||
|
||||
const files: FileChange[] = diffStat.files.map(file => {
|
||||
const additions = 'insertions' in file ? file.insertions : 0;
|
||||
const deletions = 'deletions' in file ? file.deletions : 0;
|
||||
const isBinary = 'binary' in file && file.binary;
|
||||
|
||||
return {
|
||||
path: file.file,
|
||||
status: isBinary ? 'modified' as const : (
|
||||
additions > 0 && deletions === 0 ? 'added' as const :
|
||||
deletions > 0 && additions === 0 ? 'deleted' as const : 'modified' as const
|
||||
),
|
||||
additions,
|
||||
deletions,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
files,
|
||||
totalAdditions: diffStat.insertions,
|
||||
totalDeletions: diffStat.deletions,
|
||||
totalFiles: files.length,
|
||||
};
|
||||
}
|
||||
|
||||
async getFileDiff(taskId: string, filePath: string): Promise<FileDiff> {
|
||||
const task = await this.taskService.getTask(taskId);
|
||||
if (!task?.git?.worktreePath) {
|
||||
throw new Error('Task does not have an active worktree');
|
||||
}
|
||||
|
||||
const worktreePath = this.expandPath(task.git.worktreePath);
|
||||
const git = simpleGit(worktreePath);
|
||||
const baseBranch = task.git.baseBranch || 'main';
|
||||
|
||||
// Get unified diff for the file
|
||||
const diffOutput = await git.diff([baseBranch, '--', filePath]);
|
||||
|
||||
// Get file stats
|
||||
const diffStat = await git.diffSummary([baseBranch, '--', filePath]);
|
||||
const fileStat = diffStat.files[0];
|
||||
|
||||
// Parse the unified diff
|
||||
const hunks = this.parseUnifiedDiff(diffOutput);
|
||||
|
||||
// Determine status and get stats
|
||||
let status: FileChange['status'] = 'modified';
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
|
||||
if (fileStat && 'insertions' in fileStat) {
|
||||
additions = fileStat.insertions;
|
||||
deletions = fileStat.deletions;
|
||||
if (additions > 0 && deletions === 0) status = 'added';
|
||||
else if (deletions > 0 && additions === 0) status = 'deleted';
|
||||
}
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
status,
|
||||
hunks,
|
||||
language: this.getLanguageFromPath(filePath),
|
||||
additions,
|
||||
deletions,
|
||||
};
|
||||
}
|
||||
|
||||
private parseUnifiedDiff(diffOutput: string): DiffHunk[] {
|
||||
const hunks: DiffHunk[] = [];
|
||||
const lines = diffOutput.split('\n');
|
||||
|
||||
let currentHunk: DiffHunk | null = null;
|
||||
let oldLineNum = 0;
|
||||
let newLineNum = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
// Match hunk header: @@ -start,count +start,count @@
|
||||
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
||||
|
||||
if (hunkMatch) {
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
|
||||
oldLineNum = parseInt(hunkMatch[1], 10);
|
||||
newLineNum = parseInt(hunkMatch[3], 10);
|
||||
|
||||
currentHunk = {
|
||||
oldStart: oldLineNum,
|
||||
oldLines: parseInt(hunkMatch[2] || '1', 10),
|
||||
newStart: newLineNum,
|
||||
newLines: parseInt(hunkMatch[4] || '1', 10),
|
||||
lines: [],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentHunk) continue;
|
||||
|
||||
// Skip diff metadata lines
|
||||
if (line.startsWith('diff --git') ||
|
||||
line.startsWith('index ') ||
|
||||
line.startsWith('---') ||
|
||||
line.startsWith('+++') ||
|
||||
line.startsWith('\\')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('+')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'add',
|
||||
content: line.slice(1),
|
||||
newNumber: newLineNum++,
|
||||
});
|
||||
} else if (line.startsWith('-')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'delete',
|
||||
content: line.slice(1),
|
||||
oldNumber: oldLineNum++,
|
||||
});
|
||||
} else if (line.startsWith(' ') || line === '') {
|
||||
currentHunk.lines.push({
|
||||
type: 'context',
|
||||
content: line.slice(1) || '',
|
||||
oldNumber: oldLineNum++,
|
||||
newNumber: newLineNum++,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (currentHunk && currentHunk.lines.length > 0) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
|
||||
return hunks;
|
||||
}
|
||||
|
||||
async getFullDiff(taskId: string): Promise<FileDiff[]> {
|
||||
const summary = await this.getDiffSummary(taskId);
|
||||
const diffs: FileDiff[] = [];
|
||||
|
||||
for (const file of summary.files) {
|
||||
try {
|
||||
const diff = await this.getFileDiff(taskId, file.path);
|
||||
diffs.push(diff);
|
||||
} catch (e) {
|
||||
// Skip files that can't be diffed (binary, etc.)
|
||||
console.warn(`Could not get diff for ${file.path}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
}
|
||||
25
sprint-4.md
Normal file
25
sprint-4.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Sprint 4: Code Review & Diff Viewing
|
||||
|
||||
**Goal:** Review agent changes before merging.
|
||||
|
||||
**Started:** 2026-01-26
|
||||
**Status:** In Progress
|
||||
|
||||
---
|
||||
|
||||
## Stories
|
||||
|
||||
| ID | Title | Status | Notes |
|
||||
|----|-------|--------|-------|
|
||||
| US-401 | Diff viewer component | ⏳ Todo | File tree + unified diff |
|
||||
| US-402 | Line-level comments | ⏳ Todo | Inline comment UI |
|
||||
| US-403 | Send review to agent | ⏳ Todo | Restart agent with feedback |
|
||||
| US-404 | Approve and merge | ⏳ Todo | Merge flow |
|
||||
|
||||
---
|
||||
|
||||
## Progress Log
|
||||
|
||||
### 2026-01-26
|
||||
|
||||
(Starting Sprint 4)
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
|
|
|
|||
296
web/src/components/task/DiffViewer.tsx
Normal file
296
web/src/components/task/DiffViewer.tsx
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import { useState } from 'react';
|
||||
import { useDiffSummary, useFileDiff } from '@/hooks/useDiff';
|
||||
import {
|
||||
FileCode,
|
||||
FilePlus,
|
||||
FileMinus,
|
||||
FileEdit,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import type { Task } from '@veritas-kanban/shared';
|
||||
import type { FileChange, DiffLine, DiffHunk } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DiffViewerProps {
|
||||
task: Task;
|
||||
onAddComment?: (filePath: string, lineNumber: number) => void;
|
||||
}
|
||||
|
||||
const statusIcons: Record<FileChange['status'], React.ReactNode> = {
|
||||
added: <FilePlus className="h-4 w-4 text-green-500" />,
|
||||
modified: <FileEdit className="h-4 w-4 text-amber-500" />,
|
||||
deleted: <FileMinus className="h-4 w-4 text-red-500" />,
|
||||
renamed: <FileCode className="h-4 w-4 text-blue-500" />,
|
||||
};
|
||||
|
||||
function FileTree({
|
||||
files,
|
||||
selectedFile,
|
||||
onSelectFile,
|
||||
}: {
|
||||
files: FileChange[];
|
||||
selectedFile: string | null;
|
||||
onSelectFile: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{files.map((file) => (
|
||||
<button
|
||||
key={file.path}
|
||||
onClick={() => onSelectFile(file.path)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md text-left',
|
||||
'hover:bg-muted transition-colors',
|
||||
selectedFile === file.path && 'bg-muted'
|
||||
)}
|
||||
>
|
||||
{statusIcons[file.status]}
|
||||
<span className="truncate flex-1 font-mono text-xs">{file.path}</span>
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
{file.additions > 0 && (
|
||||
<span className="text-green-500">+{file.additions}</span>
|
||||
)}
|
||||
{file.deletions > 0 && (
|
||||
<span className="text-red-500">-{file.deletions}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffHunkView({
|
||||
hunk,
|
||||
filePath,
|
||||
onAddComment,
|
||||
}: {
|
||||
hunk: DiffHunk;
|
||||
filePath: string;
|
||||
onAddComment?: (filePath: string, lineNumber: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-border last:border-b-0">
|
||||
{/* Hunk header */}
|
||||
<div className="bg-muted/50 px-4 py-1 text-xs text-muted-foreground font-mono">
|
||||
@@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
|
||||
</div>
|
||||
|
||||
{/* Lines */}
|
||||
<div className="font-mono text-xs">
|
||||
{hunk.lines.map((line, idx) => (
|
||||
<DiffLineView
|
||||
key={idx}
|
||||
line={line}
|
||||
filePath={filePath}
|
||||
onAddComment={onAddComment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffLineView({
|
||||
line,
|
||||
filePath,
|
||||
onAddComment,
|
||||
}: {
|
||||
line: DiffLine;
|
||||
filePath: string;
|
||||
onAddComment?: (filePath: string, lineNumber: number) => void;
|
||||
}) {
|
||||
const lineNumber = line.newNumber || line.oldNumber;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex hover:bg-muted/30',
|
||||
line.type === 'add' && 'bg-green-500/10',
|
||||
line.type === 'delete' && 'bg-red-500/10'
|
||||
)}
|
||||
>
|
||||
{/* Line numbers */}
|
||||
<div className="flex-shrink-0 w-20 flex text-muted-foreground select-none border-r border-border">
|
||||
<span className="w-10 px-2 text-right border-r border-border">
|
||||
{line.oldNumber || ''}
|
||||
</span>
|
||||
<span className="w-10 px-2 text-right">
|
||||
{line.newNumber || ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Add comment button */}
|
||||
{onAddComment && lineNumber && (
|
||||
<button
|
||||
onClick={() => onAddComment(filePath, lineNumber)}
|
||||
className="w-6 flex-shrink-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Change indicator */}
|
||||
<div className="w-6 flex-shrink-0 flex items-center justify-center">
|
||||
{line.type === 'add' && <span className="text-green-500">+</span>}
|
||||
{line.type === 'delete' && <span className="text-red-500">-</span>}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<pre className="flex-1 px-2 overflow-x-auto whitespace-pre">
|
||||
{line.content || ' '}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileDiffView({
|
||||
taskId,
|
||||
filePath,
|
||||
onAddComment,
|
||||
}: {
|
||||
taskId: string;
|
||||
filePath: string;
|
||||
onAddComment?: (filePath: string, lineNumber: number) => void;
|
||||
}) {
|
||||
const { data: diff, isLoading, error } = useFileDiff(taskId, filePath);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||
Loading diff...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !diff) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
<AlertCircle className="h-5 w-5 mr-2" />
|
||||
{(error as Error)?.message || 'Failed to load diff'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (diff.hunks.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
No changes in this file
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-md overflow-hidden bg-card">
|
||||
{/* File header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-muted/50 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
{statusIcons[diff.status]}
|
||||
<span className="font-mono text-sm">{diff.path}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-green-500">+{diff.additions}</span>
|
||||
<span className="text-red-500">-{diff.deletions}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hunks */}
|
||||
<div className="overflow-x-auto">
|
||||
{diff.hunks.map((hunk, idx) => (
|
||||
<DiffHunkView
|
||||
key={idx}
|
||||
hunk={hunk}
|
||||
filePath={filePath}
|
||||
onAddComment={onAddComment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiffViewer({ task, onAddComment }: DiffViewerProps) {
|
||||
const hasWorktree = !!task.git?.worktreePath;
|
||||
const { data: summary, isLoading, error } = useDiffSummary(task.id, hasWorktree);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
|
||||
if (!hasWorktree) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
<AlertCircle className="h-5 w-5 mr-2" />
|
||||
No worktree active
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||
Loading changes...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
<AlertCircle className="h-5 w-5 mr-2" />
|
||||
{(error as Error)?.message || 'Failed to load changes'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!summary || summary.files.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
No changes detected
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 h-[500px]">
|
||||
{/* File tree */}
|
||||
<div className="w-64 flex-shrink-0 border rounded-md overflow-hidden bg-card">
|
||||
<div className="px-3 py-2 border-b bg-muted/50">
|
||||
<div className="text-sm font-medium">
|
||||
Changed Files ({summary.totalFiles})
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="text-green-500">+{summary.totalAdditions}</span>
|
||||
{' / '}
|
||||
<span className="text-red-500">-{summary.totalDeletions}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 overflow-y-auto h-[calc(100%-60px)]">
|
||||
<FileTree
|
||||
files={summary.files}
|
||||
selectedFile={selectedFile}
|
||||
onSelectFile={setSelectedFile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diff view */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{selectedFile ? (
|
||||
<FileDiffView
|
||||
taskId={task.id}
|
||||
filePath={selectedFile}
|
||||
onAddComment={onAddComment}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground border rounded-md">
|
||||
<ChevronRight className="h-5 w-5 mr-2" />
|
||||
Select a file to view changes
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,12 @@ import {
|
|||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/ui/tabs';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -28,10 +34,11 @@ import {
|
|||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useUpdateTask, useDeleteTask } from '@/hooks/useTasks';
|
||||
import { Trash2, Code, Search, FileText, Zap, Calendar, Clock } from 'lucide-react';
|
||||
import { Trash2, Code, Search, FileText, Zap, Calendar, Clock, GitBranch, Bot, FileDiff } from 'lucide-react';
|
||||
import type { Task, TaskType, TaskStatus, TaskPriority } from '@veritas-kanban/shared';
|
||||
import { GitSection } from './GitSection';
|
||||
import { AgentPanel } from './AgentPanel';
|
||||
import { DiffViewer } from './DiffViewer';
|
||||
|
||||
interface TaskDetailPanelProps {
|
||||
task: Task | null;
|
||||
|
|
@ -70,13 +77,11 @@ function useDebouncedSave(task: Task | null, updateTask: ReturnType<typeof useUp
|
|||
const [localTask, setLocalTask] = useState<Task | null>(task);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
// Sync with external task when it changes
|
||||
useEffect(() => {
|
||||
setLocalTask(task);
|
||||
setIsDirty(false);
|
||||
}, [task]);
|
||||
|
||||
// Debounced save
|
||||
useEffect(() => {
|
||||
if (!isDirty || !localTask) return;
|
||||
|
||||
|
|
@ -112,15 +117,14 @@ export function TaskDetailPanel({ task, open, onOpenChange }: TaskDetailPanelPro
|
|||
const updateTask = useUpdateTask();
|
||||
const deleteTask = useDeleteTask();
|
||||
const { localTask, updateField, isDirty } = useDebouncedSave(task, updateTask);
|
||||
const [activeTab, setActiveTab] = useState('details');
|
||||
|
||||
// Handle Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && open) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onOpenChange]);
|
||||
|
|
@ -143,10 +147,13 @@ export function TaskDetailPanel({ task, open, onOpenChange }: TaskDetailPanelPro
|
|||
});
|
||||
};
|
||||
|
||||
const isCodeTask = localTask.type === 'code';
|
||||
const hasWorktree = !!localTask.git?.worktreePath;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-[500px] sm:max-w-[500px] overflow-y-auto">
|
||||
<SheetHeader className="space-y-1">
|
||||
<SheetContent className="w-[700px] sm:max-w-[700px] overflow-hidden flex flex-col">
|
||||
<SheetHeader className="space-y-1 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
{typeIcons[localTask.type]}
|
||||
<span className="text-xs uppercase tracking-wide">{typeLabels[localTask.type]} Task</span>
|
||||
|
|
@ -164,165 +171,186 @@ export function TaskDetailPanel({ task, open, onOpenChange }: TaskDetailPanelPro
|
|||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-6 space-y-6">
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Description</Label>
|
||||
<Textarea
|
||||
value={localTask.description}
|
||||
onChange={(e) => updateField('description', e.target.value)}
|
||||
placeholder="Add a description..."
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden mt-4">
|
||||
<TabsList className={`grid w-full ${isCodeTask ? 'grid-cols-4' : 'grid-cols-1'}`}>
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
{isCodeTask && (
|
||||
<>
|
||||
<TabsTrigger value="git" className="flex items-center gap-1">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
Git
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agent" className="flex items-center gap-1">
|
||||
<Bot className="h-3 w-3" />
|
||||
Agent
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="changes" disabled={!hasWorktree} className="flex items-center gap-1">
|
||||
<FileDiff className="h-3 w-3" />
|
||||
Changes
|
||||
</TabsTrigger>
|
||||
</>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{/* Status, Type, Priority grid */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Status</Label>
|
||||
<Select
|
||||
value={localTask.status}
|
||||
onValueChange={(v) => updateField('status', v as TaskStatus)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto mt-4">
|
||||
{/* Details Tab */}
|
||||
<TabsContent value="details" className="mt-0 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Description</Label>
|
||||
<Textarea
|
||||
value={localTask.description}
|
||||
onChange={(e) => updateField('description', e.target.value)}
|
||||
placeholder="Add a description..."
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Type</Label>
|
||||
<Select
|
||||
value={localTask.type}
|
||||
onValueChange={(v) => updateField('type', v as TaskType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(typeLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
<div className="flex items-center gap-2">
|
||||
{typeIcons[value as TaskType]}
|
||||
{label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Priority</Label>
|
||||
<Select
|
||||
value={localTask.priority}
|
||||
onValueChange={(v) => updateField('priority', v as TaskPriority)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(priorityLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Project</Label>
|
||||
<Input
|
||||
value={localTask.project || ''}
|
||||
onChange={(e) => updateField('project', e.target.value || undefined)}
|
||||
placeholder="Add to a project..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Tags</Label>
|
||||
<Input
|
||||
value={localTask.tags?.join(', ') || ''}
|
||||
onChange={(e) => {
|
||||
const tags = e.target.value
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean);
|
||||
updateField('tags', tags.length > 0 ? tags : undefined);
|
||||
}}
|
||||
placeholder="Add tags (comma-separated)..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Git Integration (code tasks only) */}
|
||||
{localTask.type === 'code' && (
|
||||
<GitSection
|
||||
task={localTask}
|
||||
onGitChange={(git) => updateField('git', git as Task['git'])}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI Agent (code tasks with worktree only) */}
|
||||
{localTask.type === 'code' && (
|
||||
<AgentPanel task={localTask} />
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="border-t pt-4 space-y-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Created: {formatDate(localTask.created)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Updated: {formatDate(localTask.updated)}</span>
|
||||
</div>
|
||||
<div className="text-xs font-mono opacity-50">
|
||||
ID: {localTask.id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete button */}
|
||||
<div className="border-t pt-4">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-full">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Task
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this task?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete "{localTask.title}". This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Status</Label>
|
||||
<Select
|
||||
value={localTask.status}
|
||||
onValueChange={(v) => updateField('status', v as TaskStatus)}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Type</Label>
|
||||
<Select
|
||||
value={localTask.type}
|
||||
onValueChange={(v) => updateField('type', v as TaskType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(typeLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
<div className="flex items-center gap-2">
|
||||
{typeIcons[value as TaskType]}
|
||||
{label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Priority</Label>
|
||||
<Select
|
||||
value={localTask.priority}
|
||||
onValueChange={(v) => updateField('priority', v as TaskPriority)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(priorityLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Project</Label>
|
||||
<Input
|
||||
value={localTask.project || ''}
|
||||
onChange={(e) => updateField('project', e.target.value || undefined)}
|
||||
placeholder="Add to a project..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Tags</Label>
|
||||
<Input
|
||||
value={localTask.tags?.join(', ') || ''}
|
||||
onChange={(e) => {
|
||||
const tags = e.target.value.split(',').map(t => t.trim()).filter(Boolean);
|
||||
updateField('tags', tags.length > 0 ? tags : undefined);
|
||||
}}
|
||||
placeholder="Add tags (comma-separated)..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4 space-y-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Created: {formatDate(localTask.created)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Updated: {formatDate(localTask.updated)}</span>
|
||||
</div>
|
||||
<div className="text-xs font-mono opacity-50">ID: {localTask.id}</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-full">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Task
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this task?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete "{localTask.title}".
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Git Tab */}
|
||||
{isCodeTask && (
|
||||
<TabsContent value="git" className="mt-0">
|
||||
<GitSection
|
||||
task={localTask}
|
||||
onGitChange={(git) => updateField('git', git as Task['git'])}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Agent Tab */}
|
||||
{isCodeTask && (
|
||||
<TabsContent value="agent" className="mt-0">
|
||||
<AgentPanel task={localTask} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Changes Tab */}
|
||||
{isCodeTask && hasWorktree && (
|
||||
<TabsContent value="changes" className="mt-0">
|
||||
<DiffViewer task={localTask} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
|
|
|||
53
web/src/components/ui/tabs.tsx
Normal file
53
web/src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
26
web/src/hooks/useDiff.ts
Normal file
26
web/src/hooks/useDiff.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export function useDiffSummary(taskId: string | undefined, hasWorktree: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['diff', 'summary', taskId],
|
||||
queryFn: () => api.diff.getSummary(taskId!),
|
||||
enabled: !!taskId && hasWorktree,
|
||||
});
|
||||
}
|
||||
|
||||
export function useFileDiff(taskId: string | undefined, filePath: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['diff', 'file', taskId, filePath],
|
||||
queryFn: () => api.diff.getFileDiff(taskId!, filePath!),
|
||||
enabled: !!taskId && !!filePath,
|
||||
});
|
||||
}
|
||||
|
||||
export function useFullDiff(taskId: string | undefined, hasWorktree: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['diff', 'full', taskId],
|
||||
queryFn: () => api.diff.getFullDiff(taskId!),
|
||||
enabled: !!taskId && hasWorktree,
|
||||
});
|
||||
}
|
||||
|
|
@ -215,8 +215,65 @@ export const api = {
|
|||
return response.text();
|
||||
},
|
||||
},
|
||||
|
||||
diff: {
|
||||
getSummary: async (taskId: string): Promise<DiffSummary> => {
|
||||
const response = await fetch(`${API_BASE}/diff/${taskId}`);
|
||||
return handleResponse<DiffSummary>(response);
|
||||
},
|
||||
|
||||
getFileDiff: async (taskId: string, filePath: string): Promise<FileDiff> => {
|
||||
const response = await fetch(`${API_BASE}/diff/${taskId}/file?path=${encodeURIComponent(filePath)}`);
|
||||
return handleResponse<FileDiff>(response);
|
||||
},
|
||||
|
||||
getFullDiff: async (taskId: string): Promise<FileDiff[]> => {
|
||||
const response = await fetch(`${API_BASE}/diff/${taskId}/full`);
|
||||
return handleResponse<FileDiff[]>(response);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Types for diff
|
||||
export interface FileChange {
|
||||
path: string;
|
||||
status: 'added' | 'modified' | 'deleted' | 'renamed';
|
||||
additions: number;
|
||||
deletions: number;
|
||||
oldPath?: string;
|
||||
}
|
||||
|
||||
export interface DiffSummary {
|
||||
files: FileChange[];
|
||||
totalAdditions: number;
|
||||
totalDeletions: number;
|
||||
totalFiles: number;
|
||||
}
|
||||
|
||||
export interface DiffHunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
newLines: number;
|
||||
lines: DiffLine[];
|
||||
}
|
||||
|
||||
export interface DiffLine {
|
||||
type: 'context' | 'add' | 'delete';
|
||||
content: string;
|
||||
oldNumber?: number;
|
||||
newNumber?: number;
|
||||
}
|
||||
|
||||
export interface FileDiff {
|
||||
path: string;
|
||||
status: 'added' | 'modified' | 'deleted' | 'renamed';
|
||||
hunks: DiffHunk[];
|
||||
language: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
// Types for agent
|
||||
export interface AgentStatus {
|
||||
taskId: string;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue