mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-07 04:07:50 +00:00
US-709: Add merge conflict resolution UI
- ConflictService: detect, list, resolve conflicts - Parse conflict markers, get ours/theirs/base versions - API endpoints: conflicts status, file details, resolve, abort, continue - ConflictResolver slide-out panel with: - File list sidebar - Side-by-side comparison (Ours vs Theirs) - Manual edit tab - Accept ours/theirs/manual resolution - Abort and continue actions - Conflict warning banner in GitSection
This commit is contained in:
parent
edc23f700b
commit
8facd82b04
7 changed files with 1003 additions and 3 deletions
|
|
@ -19,7 +19,7 @@
|
|||
| US-706 | Auto-archive suggestions | ✅ Done | US-701, US-703 | Suggest archiving when project complete |
|
||||
| US-707 | GitHub PR creation | ✅ Done | None | Create PR from task detail UI |
|
||||
| US-708 | Preview mode | ✅ Done | None | Embedded browser for dev server preview |
|
||||
| US-709 | Merge conflict resolution | ⏳ Todo | None | Visual conflict resolver UI |
|
||||
| US-709 | Merge conflict resolution | ✅ Done | None | Visual conflict resolver UI |
|
||||
| US-710 | Time tracking | ⏳ Todo | None | Start/stop timer, manual entry, reports |
|
||||
| US-711 | Running indicator on cards | ⏳ Todo | None | Spinner/pulse animation when agent running |
|
||||
|
||||
|
|
@ -145,4 +145,24 @@
|
|||
- Activity logging for project_archived events
|
||||
- Replaces client-side ProjectArchiveSuggestion with server-side approach
|
||||
|
||||
**US-709: Merge conflict resolution** ✅
|
||||
- ConflictService for detecting and resolving git conflicts:
|
||||
- Detect rebase/merge in progress
|
||||
- List conflicting files
|
||||
- Get file conflict details (ours, theirs, base versions)
|
||||
- Parse conflict markers
|
||||
- Resolve with ours/theirs/manual content
|
||||
- Abort or continue rebase/merge
|
||||
- API endpoints: GET /api/conflicts/:taskId, file, resolve, abort, continue
|
||||
- ConflictResolver component:
|
||||
- Slide-out panel (90vw, max 1200px)
|
||||
- File list sidebar with conflict count
|
||||
- Side-by-side comparison view (Ours vs Theirs)
|
||||
- Manual edit tab with full content editor
|
||||
- Accept Ours / Accept Theirs buttons
|
||||
- Abort and Continue actions with confirmation
|
||||
- File navigation (prev/next)
|
||||
- Conflict warning banner in GitSection when conflicts detected
|
||||
- Auto-polling when conflicts present
|
||||
|
||||
(Starting Sprint 7)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import templateRoutes from './routes/templates.js';
|
|||
import activityRoutes from './routes/activity.js';
|
||||
import githubRoutes from './routes/github.js';
|
||||
import previewRoutes from './routes/preview.js';
|
||||
import conflictRoutes from './routes/conflicts.js';
|
||||
import type { AgentOutput } from './services/agent-service.js';
|
||||
|
||||
const app = express();
|
||||
|
|
@ -39,6 +40,7 @@ app.use('/api/templates', templateRoutes);
|
|||
app.use('/api/activity', activityRoutes);
|
||||
app.use('/api/github', githubRoutes);
|
||||
app.use('/api/preview', previewRoutes);
|
||||
app.use('/api/conflicts', conflictRoutes);
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
|
|
|||
107
server/src/routes/conflicts.ts
Normal file
107
server/src/routes/conflicts.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { Router, type Router as RouterType } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { ConflictService } from '../services/conflict-service.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
const conflictService = new ConflictService();
|
||||
|
||||
// Validation schemas
|
||||
const resolveSchema = z.object({
|
||||
resolution: z.enum(['ours', 'theirs', 'manual']),
|
||||
manualContent: z.string().optional(),
|
||||
});
|
||||
|
||||
// GET /api/conflicts/:taskId - Get conflict status for a task
|
||||
router.get('/:taskId', async (req, res) => {
|
||||
try {
|
||||
const status = await conflictService.getConflictStatus(req.params.taskId);
|
||||
res.json(status);
|
||||
} catch (error: any) {
|
||||
console.error('Error getting conflict status:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to get conflict status' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/conflicts/:taskId/file - Get conflict details for a specific file
|
||||
router.get('/:taskId/file', async (req, res) => {
|
||||
try {
|
||||
const filePath = req.query.path as string;
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: 'File path required' });
|
||||
}
|
||||
|
||||
const conflict = await conflictService.getFileConflict(req.params.taskId, filePath);
|
||||
res.json(conflict);
|
||||
} catch (error: any) {
|
||||
console.error('Error getting file conflict:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to get file conflict' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/conflicts/:taskId/resolve - Resolve a file conflict
|
||||
router.post('/:taskId/resolve', async (req, res) => {
|
||||
try {
|
||||
const filePath = req.query.path as string;
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: 'File path required' });
|
||||
}
|
||||
|
||||
const input = resolveSchema.parse(req.body);
|
||||
const result = await conflictService.resolveFile(
|
||||
req.params.taskId,
|
||||
filePath,
|
||||
input.resolution,
|
||||
input.manualContent
|
||||
);
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ error: 'Validation failed', details: error.errors });
|
||||
}
|
||||
console.error('Error resolving conflict:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to resolve conflict' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/conflicts/:taskId/abort - Abort rebase or merge
|
||||
router.post('/:taskId/abort', async (req, res) => {
|
||||
try {
|
||||
const status = await conflictService.getConflictStatus(req.params.taskId);
|
||||
|
||||
if (status.rebaseInProgress) {
|
||||
await conflictService.abortRebase(req.params.taskId);
|
||||
} else if (status.mergeInProgress) {
|
||||
await conflictService.abortMerge(req.params.taskId);
|
||||
} else {
|
||||
return res.status(400).json({ error: 'No rebase or merge in progress' });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error('Error aborting:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to abort' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/conflicts/:taskId/continue - Continue rebase or merge
|
||||
router.post('/:taskId/continue', async (req, res) => {
|
||||
try {
|
||||
const status = await conflictService.getConflictStatus(req.params.taskId);
|
||||
|
||||
let result;
|
||||
if (status.rebaseInProgress) {
|
||||
result = await conflictService.continueRebase(req.params.taskId);
|
||||
} else if (status.mergeInProgress) {
|
||||
result = await conflictService.continueMerge(req.params.taskId, req.body.message);
|
||||
} else {
|
||||
return res.status(400).json({ error: 'No rebase or merge in progress' });
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
console.error('Error continuing:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to continue' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
288
server/src/services/conflict-service.ts
Normal file
288
server/src/services/conflict-service.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import { simpleGit, SimpleGit } from 'simple-git';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { ConfigService } from './config-service.js';
|
||||
import { TaskService } from './task-service.js';
|
||||
|
||||
export interface ConflictFile {
|
||||
path: string;
|
||||
content: string;
|
||||
oursContent: string;
|
||||
theirsContent: string;
|
||||
baseContent: string;
|
||||
markers: ConflictMarker[];
|
||||
}
|
||||
|
||||
export interface ConflictMarker {
|
||||
startLine: number;
|
||||
separatorLine: number;
|
||||
endLine: number;
|
||||
oursLines: string[];
|
||||
theirsLines: string[];
|
||||
}
|
||||
|
||||
export interface ConflictStatus {
|
||||
hasConflicts: boolean;
|
||||
conflictingFiles: string[];
|
||||
rebaseInProgress: boolean;
|
||||
mergeInProgress: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveResult {
|
||||
success: boolean;
|
||||
remainingConflicts: string[];
|
||||
}
|
||||
|
||||
export class ConflictService {
|
||||
private configService: ConfigService;
|
||||
private taskService: TaskService;
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService();
|
||||
this.taskService = new TaskService();
|
||||
}
|
||||
|
||||
private expandPath(p: string): string {
|
||||
return p.replace(/^~/, process.env.HOME || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the working directory for a task (worktree or repo)
|
||||
*/
|
||||
private async getWorkingDir(taskId: string): Promise<{ git: SimpleGit; workDir: string }> {
|
||||
const task = await this.taskService.getTask(taskId);
|
||||
if (!task?.git?.repo) {
|
||||
throw new Error('Task must have a repository configured');
|
||||
}
|
||||
|
||||
const config = await this.configService.getConfig();
|
||||
const repoConfig = config.repos.find(r => r.name === task.git!.repo);
|
||||
if (!repoConfig) {
|
||||
throw new Error(`Repository "${task.git.repo}" not found`);
|
||||
}
|
||||
|
||||
const workDir = task.git.worktreePath || this.expandPath(repoConfig.path);
|
||||
const git = simpleGit(workDir);
|
||||
|
||||
return { git, workDir };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are conflicts in the working directory
|
||||
*/
|
||||
async getConflictStatus(taskId: string): Promise<ConflictStatus> {
|
||||
const { git, workDir } = await this.getWorkingDir(taskId);
|
||||
|
||||
// Check for rebase in progress
|
||||
const rebaseDir = path.join(workDir, '.git', 'rebase-merge');
|
||||
const rebaseApplyDir = path.join(workDir, '.git', 'rebase-apply');
|
||||
const rebaseInProgress = await fs.access(rebaseDir).then(() => true).catch(() => false) ||
|
||||
await fs.access(rebaseApplyDir).then(() => true).catch(() => false);
|
||||
|
||||
// Check for merge in progress
|
||||
const mergeHead = path.join(workDir, '.git', 'MERGE_HEAD');
|
||||
const mergeInProgress = await fs.access(mergeHead).then(() => true).catch(() => false);
|
||||
|
||||
// Get status to find conflicted files
|
||||
const status = await git.status();
|
||||
const conflictingFiles = status.conflicted || [];
|
||||
|
||||
return {
|
||||
hasConflicts: conflictingFiles.length > 0,
|
||||
conflictingFiles,
|
||||
rebaseInProgress,
|
||||
mergeInProgress,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed conflict information for a file
|
||||
*/
|
||||
async getFileConflict(taskId: string, filePath: string): Promise<ConflictFile> {
|
||||
const { git, workDir } = await this.getWorkingDir(taskId);
|
||||
|
||||
const fullPath = path.join(workDir, filePath);
|
||||
const content = await fs.readFile(fullPath, 'utf-8');
|
||||
|
||||
// Parse conflict markers
|
||||
const markers = this.parseConflictMarkers(content);
|
||||
|
||||
// Get the different versions
|
||||
let oursContent = '';
|
||||
let theirsContent = '';
|
||||
let baseContent = '';
|
||||
|
||||
try {
|
||||
// Get our version (HEAD)
|
||||
oursContent = await git.show([`:2:${filePath}`]);
|
||||
} catch {
|
||||
// File might be new on our side
|
||||
}
|
||||
|
||||
try {
|
||||
// Get their version (incoming)
|
||||
theirsContent = await git.show([`:3:${filePath}`]);
|
||||
} catch {
|
||||
// File might be new on their side
|
||||
}
|
||||
|
||||
try {
|
||||
// Get base version (common ancestor)
|
||||
baseContent = await git.show([`:1:${filePath}`]);
|
||||
} catch {
|
||||
// File might not have a common ancestor
|
||||
}
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
content,
|
||||
oursContent,
|
||||
theirsContent,
|
||||
baseContent,
|
||||
markers,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse conflict markers from file content
|
||||
*/
|
||||
private parseConflictMarkers(content: string): ConflictMarker[] {
|
||||
const lines = content.split('\n');
|
||||
const markers: ConflictMarker[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
if (lines[i].startsWith('<<<<<<<')) {
|
||||
const startLine = i;
|
||||
const oursLines: string[] = [];
|
||||
const theirsLines: string[] = [];
|
||||
|
||||
// Collect "ours" lines
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].startsWith('=======')) {
|
||||
oursLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
const separatorLine = i;
|
||||
|
||||
// Collect "theirs" lines
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].startsWith('>>>>>>>')) {
|
||||
theirsLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
const endLine = i;
|
||||
|
||||
markers.push({
|
||||
startLine,
|
||||
separatorLine,
|
||||
endLine,
|
||||
oursLines,
|
||||
theirsLines,
|
||||
});
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a file conflict by choosing a side
|
||||
*/
|
||||
async resolveFile(
|
||||
taskId: string,
|
||||
filePath: string,
|
||||
resolution: 'ours' | 'theirs' | 'manual',
|
||||
manualContent?: string
|
||||
): Promise<ResolveResult> {
|
||||
const { git, workDir } = await this.getWorkingDir(taskId);
|
||||
const fullPath = path.join(workDir, filePath);
|
||||
|
||||
if (resolution === 'manual') {
|
||||
if (!manualContent) {
|
||||
throw new Error('Manual content required for manual resolution');
|
||||
}
|
||||
await fs.writeFile(fullPath, manualContent, 'utf-8');
|
||||
} else if (resolution === 'ours') {
|
||||
// Checkout our version
|
||||
await git.checkout(['--ours', filePath]);
|
||||
} else if (resolution === 'theirs') {
|
||||
// Checkout their version
|
||||
await git.checkout(['--theirs', filePath]);
|
||||
}
|
||||
|
||||
// Stage the resolved file
|
||||
await git.add(filePath);
|
||||
|
||||
// Check remaining conflicts
|
||||
const status = await this.getConflictStatus(taskId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
remainingConflicts: status.conflictingFiles,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort the current rebase
|
||||
*/
|
||||
async abortRebase(taskId: string): Promise<void> {
|
||||
const { git } = await this.getWorkingDir(taskId);
|
||||
await git.rebase(['--abort']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue rebase after conflicts are resolved
|
||||
*/
|
||||
async continueRebase(taskId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const { git } = await this.getWorkingDir(taskId);
|
||||
|
||||
try {
|
||||
await git.rebase(['--continue']);
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
// Check if there are still conflicts
|
||||
const status = await this.getConflictStatus(taskId);
|
||||
if (status.hasConflicts) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Still have ${status.conflictingFiles.length} conflicting file(s)`
|
||||
};
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort the current merge
|
||||
*/
|
||||
async abortMerge(taskId: string): Promise<void> {
|
||||
const { git } = await this.getWorkingDir(taskId);
|
||||
await git.merge(['--abort']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue/finish merge after conflicts are resolved
|
||||
*/
|
||||
async continueMerge(taskId: string, commitMessage?: string): Promise<{ success: boolean; error?: string }> {
|
||||
const { git } = await this.getWorkingDir(taskId);
|
||||
|
||||
try {
|
||||
// Commit the merge
|
||||
await git.commit(commitMessage || 'Merge conflict resolution');
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
const status = await this.getConflictStatus(taskId);
|
||||
if (status.hasConflicts) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Still have ${status.conflictingFiles.length} conflicting file(s)`
|
||||
};
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
376
web/src/components/task/ConflictResolver.tsx
Normal file
376
web/src/components/task/ConflictResolver.tsx
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from '@/components/ui/sheet';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
useConflictStatus,
|
||||
useFileConflict,
|
||||
useResolveConflict,
|
||||
useAbortConflict,
|
||||
useContinueConflict,
|
||||
} from '@/hooks/useConflicts';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileWarning,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
GitMerge,
|
||||
} from 'lucide-react';
|
||||
import type { Task } from '@veritas-kanban/shared';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ConflictResolverProps {
|
||||
task: Task;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ConflictResolver({ task, open, onOpenChange }: ConflictResolverProps) {
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [manualContent, setManualContent] = useState('');
|
||||
const [showAbortDialog, setShowAbortDialog] = useState(false);
|
||||
|
||||
const { data: status, isLoading: statusLoading } = useConflictStatus(open ? task.id : undefined);
|
||||
const { data: fileConflict, isLoading: fileLoading } = useFileConflict(
|
||||
open && selectedFile ? task.id : undefined,
|
||||
selectedFile || undefined
|
||||
);
|
||||
|
||||
const resolveConflict = useResolveConflict();
|
||||
const abortConflict = useAbortConflict();
|
||||
const continueConflict = useContinueConflict();
|
||||
|
||||
// Auto-select first file if none selected
|
||||
if (status?.conflictingFiles.length && !selectedFile) {
|
||||
setSelectedFile(status.conflictingFiles[0]);
|
||||
}
|
||||
|
||||
const handleResolve = async (resolution: 'ours' | 'theirs' | 'manual') => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
await resolveConflict.mutateAsync({
|
||||
taskId: task.id,
|
||||
filePath: selectedFile,
|
||||
resolution,
|
||||
manualContent: resolution === 'manual' ? manualContent : undefined,
|
||||
});
|
||||
|
||||
// Move to next file or close if done
|
||||
const remaining = status?.conflictingFiles.filter(f => f !== selectedFile) || [];
|
||||
if (remaining.length > 0) {
|
||||
setSelectedFile(remaining[0]);
|
||||
} else {
|
||||
setSelectedFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAbort = async () => {
|
||||
await abortConflict.mutateAsync(task.id);
|
||||
setShowAbortDialog(false);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
const result = await continueConflict.mutateAsync({ taskId: task.id });
|
||||
if (result.success) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentIndex = selectedFile && status?.conflictingFiles
|
||||
? status.conflictingFiles.indexOf(selectedFile)
|
||||
: -1;
|
||||
|
||||
const navigateFile = (direction: 'prev' | 'next') => {
|
||||
if (!status?.conflictingFiles.length) return;
|
||||
|
||||
let newIndex = currentIndex;
|
||||
if (direction === 'prev') {
|
||||
newIndex = currentIndex > 0 ? currentIndex - 1 : status.conflictingFiles.length - 1;
|
||||
} else {
|
||||
newIndex = currentIndex < status.conflictingFiles.length - 1 ? currentIndex + 1 : 0;
|
||||
}
|
||||
setSelectedFile(status.conflictingFiles[newIndex]);
|
||||
};
|
||||
|
||||
// Initialize manual content when file changes
|
||||
if (fileConflict && manualContent !== fileConflict.content) {
|
||||
setManualContent(fileConflict.content);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-[90vw] sm:max-w-[1200px] p-0 flex flex-col">
|
||||
<SheetHeader className="px-6 py-4 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
Merge Conflicts
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{status?.rebaseInProgress ? 'Rebase' : 'Merge'} has conflicts that need to be resolved
|
||||
</SheetDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{status?.conflictingFiles.length === 0 && (
|
||||
<Button onClick={handleContinue} disabled={continueConflict.isPending}>
|
||||
{continueConflict.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<GitMerge className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Continue {status?.rebaseInProgress ? 'Rebase' : 'Merge'}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setShowAbortDialog(true)}>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Abort
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* File list sidebar */}
|
||||
<div className="w-64 border-r flex flex-col">
|
||||
<div className="p-3 border-b bg-muted/50">
|
||||
<h3 className="text-sm font-medium">
|
||||
Conflicting Files ({status?.conflictingFiles.length || 0})
|
||||
</h3>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
{statusLoading ? (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin mx-auto mb-2" />
|
||||
Loading...
|
||||
</div>
|
||||
) : status?.conflictingFiles.length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
<Check className="h-8 w-8 mx-auto mb-2 text-green-500" />
|
||||
All conflicts resolved!
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{status?.conflictingFiles.map((file) => (
|
||||
<button
|
||||
key={file}
|
||||
onClick={() => setSelectedFile(file)}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded-md text-sm truncate",
|
||||
"hover:bg-muted transition-colors",
|
||||
selectedFile === file && "bg-muted font-medium"
|
||||
)}
|
||||
>
|
||||
<FileWarning className="h-3 w-3 inline mr-2 text-amber-500" />
|
||||
{file.split('/').pop()}
|
||||
<span className="text-xs text-muted-foreground block truncate pl-5">
|
||||
{file}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{selectedFile && fileConflict ? (
|
||||
<>
|
||||
{/* File header with navigation */}
|
||||
<div className="px-4 py-2 border-b bg-muted/30 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigateFile('prev')}
|
||||
disabled={!status?.conflictingFiles.length}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
{currentIndex + 1} of {status?.conflictingFiles.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigateFile('next')}
|
||||
disabled={!status?.conflictingFiles.length}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded">
|
||||
{selectedFile}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Conflict viewer tabs */}
|
||||
<Tabs defaultValue="sidebyside" className="flex-1 flex flex-col overflow-hidden">
|
||||
<TabsList className="mx-4 mt-2 w-fit">
|
||||
<TabsTrigger value="sidebyside">Side by Side</TabsTrigger>
|
||||
<TabsTrigger value="manual">Manual Edit</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Side by side view */}
|
||||
<TabsContent value="sidebyside" className="flex-1 overflow-hidden m-0 p-4">
|
||||
<div className="grid grid-cols-2 gap-4 h-full">
|
||||
{/* Ours */}
|
||||
<div className="flex flex-col border rounded-lg overflow-hidden">
|
||||
<div className="px-3 py-2 bg-blue-500/10 border-b flex items-center justify-between">
|
||||
<span className="text-sm font-medium flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Ours (Current)
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleResolve('ours')}
|
||||
disabled={resolveConflict.isPending}
|
||||
>
|
||||
{resolveConflict.isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Accept Ours
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<pre className="p-3 text-xs font-mono whitespace-pre-wrap">
|
||||
{fileConflict.oursContent || '(empty)'}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Theirs */}
|
||||
<div className="flex flex-col border rounded-lg overflow-hidden">
|
||||
<div className="px-3 py-2 bg-green-500/10 border-b flex items-center justify-between">
|
||||
<span className="text-sm font-medium flex items-center gap-2">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
Theirs (Incoming)
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleResolve('theirs')}
|
||||
disabled={resolveConflict.isPending}
|
||||
>
|
||||
{resolveConflict.isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Accept Theirs
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<pre className="p-3 text-xs font-mono whitespace-pre-wrap">
|
||||
{fileConflict.theirsContent || '(empty)'}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Manual edit view */}
|
||||
<TabsContent value="manual" className="flex-1 overflow-hidden m-0 p-4 flex flex-col">
|
||||
<div className="flex-1 flex flex-col border rounded-lg overflow-hidden">
|
||||
<div className="px-3 py-2 bg-muted/50 border-b flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
Manual Resolution
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleResolve('manual')}
|
||||
disabled={resolveConflict.isPending}
|
||||
>
|
||||
{resolveConflict.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Save Resolution
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={manualContent}
|
||||
onChange={(e) => setManualContent(e.target.value)}
|
||||
className="flex-1 font-mono text-xs resize-none border-0 rounded-none focus-visible:ring-0"
|
||||
placeholder="Edit the file content to resolve conflicts..."
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
) : fileLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
Select a file to resolve conflicts
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Abort confirmation dialog */}
|
||||
<AlertDialog open={showAbortDialog} onOpenChange={setShowAbortDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Abort {status?.rebaseInProgress ? 'Rebase' : 'Merge'}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will discard all conflict resolutions and return to the state before the
|
||||
{status?.rebaseInProgress ? ' rebase' : ' merge'} started.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleAbort}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{abortConflict.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : null}
|
||||
Abort
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -40,6 +40,8 @@ import {
|
|||
useMergeWorktree
|
||||
} from '@/hooks/useWorktree';
|
||||
import { useCreatePR, useGitHubStatus } from '@/hooks/useGitHub';
|
||||
import { useConflictStatus } from '@/hooks/useConflicts';
|
||||
import { ConflictResolver } from './ConflictResolver';
|
||||
import {
|
||||
GitBranch,
|
||||
FolderGit2,
|
||||
|
|
@ -54,6 +56,7 @@ import {
|
|||
ArrowUp,
|
||||
ArrowDown,
|
||||
GitPullRequest,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import type { Task, TaskGit } from '@veritas-kanban/shared';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -76,6 +79,7 @@ function WorktreeStatus({ task }: { task: Task }) {
|
|||
const hasPR = !!task.git?.prUrl;
|
||||
const { data: status, isLoading, error } = useWorktreeStatus(task.id, hasWorktree);
|
||||
const { data: ghStatus } = useGitHubStatus();
|
||||
const { data: conflictStatus } = useConflictStatus(hasWorktree ? task.id : undefined);
|
||||
|
||||
const createWorktree = useCreateWorktree();
|
||||
const deleteWorktree = useDeleteWorktree();
|
||||
|
|
@ -88,6 +92,9 @@ function WorktreeStatus({ task }: { task: Task }) {
|
|||
const [prTitle, setPrTitle] = useState(task.title);
|
||||
const [prBody, setPrBody] = useState(task.description || '');
|
||||
const [prDraft, setPrDraft] = useState(false);
|
||||
|
||||
// Conflict resolver state
|
||||
const [conflictResolverOpen, setConflictResolverOpen] = useState(false);
|
||||
|
||||
const handleOpenInVSCode = () => {
|
||||
if (task.git?.worktreePath) {
|
||||
|
|
@ -175,11 +182,42 @@ function WorktreeStatus({ task }: { task: Task }) {
|
|||
// Show worktree status
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t space-y-3">
|
||||
{/* Conflict warning banner */}
|
||||
{conflictStatus?.hasConflicts && (
|
||||
<div className="flex items-center justify-between gap-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">
|
||||
{conflictStatus.conflictingFiles.length} conflict{conflictStatus.conflictingFiles.length !== 1 ? 's' : ''} detected
|
||||
</p>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-500">
|
||||
{conflictStatus.rebaseInProgress ? 'Rebase' : 'Merge'} requires manual resolution
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConflictResolverOpen(true)}
|
||||
className="border-amber-500/30 hover:bg-amber-500/10"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 mr-1" />
|
||||
Resolve Conflicts
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status indicators */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span className="text-muted-foreground">Worktree active</span>
|
||||
<span className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
conflictStatus?.hasConflicts ? "bg-amber-500" : "bg-green-500"
|
||||
)} />
|
||||
<span className="text-muted-foreground">
|
||||
{conflictStatus?.hasConflicts ? 'Conflicts detected' : 'Worktree active'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{status && (
|
||||
|
|
@ -382,6 +420,13 @@ function WorktreeStatus({ task }: { task: Task }) {
|
|||
<div className="text-xs text-muted-foreground font-mono truncate">
|
||||
{task.git.worktreePath}
|
||||
</div>
|
||||
|
||||
{/* Conflict Resolver */}
|
||||
<ConflictResolver
|
||||
task={task}
|
||||
open={conflictResolverOpen}
|
||||
onOpenChange={setConflictResolverOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
162
web/src/hooks/useConflicts.ts
Normal file
162
web/src/hooks/useConflicts.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
export interface ConflictStatus {
|
||||
hasConflicts: boolean;
|
||||
conflictingFiles: string[];
|
||||
rebaseInProgress: boolean;
|
||||
mergeInProgress: boolean;
|
||||
}
|
||||
|
||||
export interface ConflictMarker {
|
||||
startLine: number;
|
||||
separatorLine: number;
|
||||
endLine: number;
|
||||
oursLines: string[];
|
||||
theirsLines: string[];
|
||||
}
|
||||
|
||||
export interface ConflictFile {
|
||||
path: string;
|
||||
content: string;
|
||||
oursContent: string;
|
||||
theirsContent: string;
|
||||
baseContent: string;
|
||||
markers: ConflictMarker[];
|
||||
}
|
||||
|
||||
export interface ResolveResult {
|
||||
success: boolean;
|
||||
remainingConflicts: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conflict status for a task
|
||||
*/
|
||||
export function useConflictStatus(taskId: string | undefined) {
|
||||
return useQuery<ConflictStatus>({
|
||||
queryKey: ['conflicts', taskId],
|
||||
queryFn: async () => {
|
||||
if (!taskId) return { hasConflicts: false, conflictingFiles: [], rebaseInProgress: false, mergeInProgress: false };
|
||||
const response = await fetch(`${API_BASE}/conflicts/${taskId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get conflict status');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
enabled: !!taskId,
|
||||
refetchInterval: (query) => {
|
||||
// Poll when there are conflicts
|
||||
if (query.state.data?.hasConflicts) {
|
||||
return 5000;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conflict details for a specific file
|
||||
*/
|
||||
export function useFileConflict(taskId: string | undefined, filePath: string | undefined) {
|
||||
return useQuery<ConflictFile>({
|
||||
queryKey: ['conflicts', taskId, 'file', filePath],
|
||||
queryFn: async () => {
|
||||
if (!taskId || !filePath) throw new Error('Task ID and file path required');
|
||||
const response = await fetch(`${API_BASE}/conflicts/${taskId}/file?path=${encodeURIComponent(filePath)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get file conflict');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
enabled: !!taskId && !!filePath,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a file conflict
|
||||
*/
|
||||
export function useResolveConflict() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<ResolveResult, Error, {
|
||||
taskId: string;
|
||||
filePath: string;
|
||||
resolution: 'ours' | 'theirs' | 'manual';
|
||||
manualContent?: string;
|
||||
}>({
|
||||
mutationFn: async ({ taskId, filePath, resolution, manualContent }) => {
|
||||
const response = await fetch(`${API_BASE}/conflicts/${taskId}/resolve?path=${encodeURIComponent(filePath)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ resolution, manualContent }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to resolve conflict');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['conflicts', variables.taskId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['conflicts', variables.taskId, 'file'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort rebase or merge
|
||||
*/
|
||||
export function useAbortConflict() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ success: boolean }, Error, string>({
|
||||
mutationFn: async (taskId) => {
|
||||
const response = await fetch(`${API_BASE}/conflicts/${taskId}/abort`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to abort');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (_data, taskId) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['conflicts', taskId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['worktree', taskId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue rebase or merge after resolving conflicts
|
||||
*/
|
||||
export function useContinueConflict() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ success: boolean; error?: string }, Error, { taskId: string; message?: string }>({
|
||||
mutationFn: async ({ taskId, message }) => {
|
||||
const response = await fetch(`${API_BASE}/conflicts/${taskId}/continue`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to continue');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['conflicts', variables.taskId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['worktree', variables.taskId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue