@@ -98,6 +98,8 @@ interface DiffViewerProps {
|
||||
hideHeader?: boolean;
|
||||
/** Callback to open file in editor */
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
/** External word wrap override (controlled mode) */
|
||||
wordWrap?: boolean;
|
||||
}
|
||||
|
||||
type AnnotationMetadata = {
|
||||
@@ -121,6 +123,7 @@ function arePropsEqual(prevProps: DiffViewerProps, nextProps: DiffViewerProps):
|
||||
if (prevProps.hideHeader !== nextProps.hideHeader) return false;
|
||||
if (prevProps.className !== nextProps.className) return false;
|
||||
if (prevProps.onOpenFile !== nextProps.onOpenFile) return false;
|
||||
if (prevProps.wordWrap !== nextProps.wordWrap) return false;
|
||||
|
||||
// Comments array - compare by length and IDs
|
||||
const prevComments = prevProps.comments;
|
||||
@@ -148,6 +151,7 @@ export const DiffViewer = memo(function DiffViewer({
|
||||
compact = false,
|
||||
hideHeader = false,
|
||||
onOpenFile,
|
||||
wordWrap: wordWrapProp,
|
||||
}: DiffViewerProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [selectedLines, setSelectedLines] = useState<SelectedLineRange | null>(null);
|
||||
@@ -156,8 +160,9 @@ export const DiffViewer = memo(function DiffViewer({
|
||||
// Global view mode (synced via localStorage)
|
||||
const [globalViewMode, setGlobalViewMode] = useGlobalViewMode();
|
||||
|
||||
// Local state for word wrap (per-diff)
|
||||
const [wordWrap, setWordWrap] = useState(false);
|
||||
// Local state for word wrap (per-diff), overridable via prop
|
||||
const [wordWrapLocal, setWordWrap] = useState(false);
|
||||
const wordWrap = wordWrapProp ?? wordWrapLocal;
|
||||
|
||||
// Use internal comment management if sessionId provided
|
||||
const {
|
||||
|
||||
@@ -30,6 +30,8 @@ interface FileDiffViewerProps {
|
||||
hideHeader?: boolean;
|
||||
/** Callback to open file in editor */
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
/** External word wrap override */
|
||||
wordWrap?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,6 +56,7 @@ export const FileDiffViewer = memo(function FileDiffViewer({
|
||||
compact,
|
||||
hideHeader,
|
||||
onOpenFile,
|
||||
wordWrap,
|
||||
}: FileDiffViewerProps) {
|
||||
// Memoize the transformation - only recalculates when raw data changes
|
||||
const data = useMemo(
|
||||
@@ -73,6 +76,7 @@ export const FileDiffViewer = memo(function FileDiffViewer({
|
||||
compact={compact}
|
||||
hideHeader={hideHeader}
|
||||
onOpenFile={onOpenFile}
|
||||
wordWrap={wordWrap}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useMemo, useCallback, createRef, useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@kandev/ui/dialog';
|
||||
import type { DiffComment } from '@/lib/diff/types';
|
||||
import type { FileInfo, CumulativeDiff } from '@/lib/state/slices/session-runtime/types';
|
||||
import { useDiffCommentsStore } from '@/lib/state/slices/diff-comments/diff-comments-slice';
|
||||
import { useSessionFileReviews } from '@/hooks/use-session-file-reviews';
|
||||
import { useGitOperations } from '@/hooks/use-git-operations';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import { ReviewTopBar } from './review-top-bar';
|
||||
import { ReviewFileTree } from './review-file-tree';
|
||||
import { ReviewDiffList } from './review-diff-list';
|
||||
import type { ReviewFile } from './types';
|
||||
import { hashDiff, normalizeDiffContent } from './types';
|
||||
|
||||
type ReviewDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sessionId: string;
|
||||
baseBranch?: string;
|
||||
taskTitle?: string;
|
||||
onSendComments: (comments: DiffComment[]) => void;
|
||||
gitStatusFiles: Record<string, FileInfo> | null;
|
||||
cumulativeDiff: CumulativeDiff | null;
|
||||
};
|
||||
|
||||
|
||||
export const ReviewDialog = memo(function ReviewDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
sessionId,
|
||||
baseBranch,
|
||||
taskTitle,
|
||||
onSendComments,
|
||||
gitStatusFiles,
|
||||
cumulativeDiff,
|
||||
}: ReviewDialogProps) {
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [splitView, setSplitView] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return localStorage.getItem('diff-view-mode') === 'split';
|
||||
});
|
||||
const [wordWrap, setWordWrap] = useState(false);
|
||||
const autoMarkOnScroll = useAppStore((s) => s.userSettings.reviewAutoMarkOnScroll);
|
||||
|
||||
const { reviews, markReviewed, markUnreviewed } =
|
||||
useSessionFileReviews(sessionId);
|
||||
|
||||
const bySession = useDiffCommentsStore((s) => s.bySession);
|
||||
const getPendingComments = useDiffCommentsStore((s) => s.getPendingComments);
|
||||
const markCommentsSent = useDiffCommentsStore((s) => s.markCommentsSent);
|
||||
|
||||
const { discard } = useGitOperations(sessionId);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Merge uncommitted + committed files into a single list
|
||||
const allFiles = useMemo<ReviewFile[]>(() => {
|
||||
const fileMap = new Map<string, ReviewFile>();
|
||||
|
||||
// Uncommitted changes from git status
|
||||
if (gitStatusFiles) {
|
||||
for (const [path, file] of Object.entries(gitStatusFiles)) {
|
||||
const diff = file.diff ? normalizeDiffContent(file.diff) : '';
|
||||
if (diff) {
|
||||
fileMap.set(path, {
|
||||
path,
|
||||
diff,
|
||||
status: file.status,
|
||||
additions: file.additions ?? 0,
|
||||
deletions: file.deletions ?? 0,
|
||||
staged: file.staged,
|
||||
source: 'uncommitted',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Committed changes from cumulative diff
|
||||
if (cumulativeDiff?.files) {
|
||||
for (const [path, file] of Object.entries(cumulativeDiff.files)) {
|
||||
if (fileMap.has(path)) continue; // Uncommitted takes priority
|
||||
const diff = file.diff ? normalizeDiffContent(file.diff) : '';
|
||||
if (diff) {
|
||||
fileMap.set(path, {
|
||||
path,
|
||||
diff,
|
||||
status: file.status || 'modified',
|
||||
additions: file.additions ?? 0,
|
||||
deletions: file.deletions ?? 0,
|
||||
staged: false,
|
||||
source: 'committed',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(fileMap.values()).sort((a, b) => a.path.localeCompare(b.path));
|
||||
}, [gitStatusFiles, cumulativeDiff]);
|
||||
|
||||
// Compute stale files and reviewed files sets
|
||||
const { reviewedFiles, staleFiles } = useMemo(() => {
|
||||
const reviewed = new Set<string>();
|
||||
const stale = new Set<string>();
|
||||
|
||||
for (const file of allFiles) {
|
||||
const reviewState = reviews.get(file.path);
|
||||
if (!reviewState?.reviewed) continue;
|
||||
|
||||
const currentHash = hashDiff(file.diff);
|
||||
if (reviewState.diffHash && reviewState.diffHash !== currentHash) {
|
||||
stale.add(file.path);
|
||||
} else {
|
||||
reviewed.add(file.path);
|
||||
}
|
||||
}
|
||||
|
||||
return { reviewedFiles: reviewed, staleFiles: stale };
|
||||
}, [allFiles, reviews]);
|
||||
|
||||
// Compute comment counts per file
|
||||
const commentCountByFile = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
const sessionComments = bySession[sessionId];
|
||||
if (!sessionComments) return counts;
|
||||
|
||||
for (const [filePath, comments] of Object.entries(sessionComments)) {
|
||||
if (comments.length > 0) {
|
||||
counts[filePath] = comments.length;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [bySession, sessionId]);
|
||||
|
||||
const totalCommentCount = useMemo(() => {
|
||||
return Object.values(commentCountByFile).reduce((sum, c) => sum + c, 0);
|
||||
}, [commentCountByFile]);
|
||||
|
||||
// File refs for scrollIntoView
|
||||
const fileRefs = useMemo(() => {
|
||||
const refs = new Map<string, React.RefObject<HTMLDivElement | null>>();
|
||||
for (const file of allFiles) {
|
||||
refs.set(file.path, createRef<HTMLDivElement>());
|
||||
}
|
||||
return refs;
|
||||
}, [allFiles]);
|
||||
|
||||
const handleToggleSplitView = useCallback((split: boolean) => {
|
||||
setSplitView(split);
|
||||
const mode = split ? 'split' : 'unified';
|
||||
localStorage.setItem('diff-view-mode', mode);
|
||||
window.dispatchEvent(new CustomEvent('diff-view-mode-change', { detail: mode }));
|
||||
}, []);
|
||||
|
||||
const handleSelectFile = useCallback(
|
||||
(path: string) => {
|
||||
setSelectedFile(path);
|
||||
const ref = fileRefs.get(path);
|
||||
if (ref?.current) {
|
||||
ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
},
|
||||
[fileRefs]
|
||||
);
|
||||
|
||||
const handleToggleReviewed = useCallback(
|
||||
(path: string, reviewed: boolean) => {
|
||||
if (reviewed) {
|
||||
const file = allFiles.find((f) => f.path === path);
|
||||
const diffHash = file ? hashDiff(file.diff) : '';
|
||||
markReviewed(path, diffHash);
|
||||
} else {
|
||||
markUnreviewed(path);
|
||||
}
|
||||
},
|
||||
[allFiles, markReviewed, markUnreviewed]
|
||||
);
|
||||
|
||||
const handleSendComments = useCallback(
|
||||
(comments: DiffComment[]) => {
|
||||
onSendComments(comments);
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onSendComments, onOpenChange]
|
||||
);
|
||||
|
||||
const handleDiscard = useCallback(
|
||||
async (path: string) => {
|
||||
try {
|
||||
const result = await discard([path]);
|
||||
if (result.success) {
|
||||
toast({ title: 'Changes discarded', description: path, variant: 'success' });
|
||||
} else {
|
||||
toast({ title: 'Discard failed', description: result.error || 'An error occurred', variant: 'error' });
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ title: 'Discard failed', description: e instanceof Error ? e.message : 'An error occurred', variant: 'error' });
|
||||
}
|
||||
},
|
||||
[discard, toast]
|
||||
);
|
||||
|
||||
const reviewedCount = reviewedFiles.size;
|
||||
const totalCount = allFiles.length;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-7xl max-w-[calc(100vw-2rem)] max-h-[85vh] w-full h-[85vh] p-0 gap-0 flex flex-col"
|
||||
showCloseButton={false}
|
||||
overlayClassName="backdrop-blur-none bg-black/40"
|
||||
>
|
||||
<DialogTitle className="sr-only">Review Changes</DialogTitle>
|
||||
{/* Top bar */}
|
||||
<ReviewTopBar
|
||||
sessionId={sessionId}
|
||||
reviewedCount={reviewedCount}
|
||||
totalCount={totalCount}
|
||||
commentCount={totalCommentCount}
|
||||
baseBranch={baseBranch}
|
||||
taskTitle={taskTitle}
|
||||
splitView={splitView}
|
||||
onToggleSplitView={handleToggleSplitView}
|
||||
wordWrap={wordWrap}
|
||||
onToggleWordWrap={setWordWrap}
|
||||
onSendComments={handleSendComments}
|
||||
onClose={() => onOpenChange(false)}
|
||||
getPendingComments={getPendingComments}
|
||||
markCommentsSent={markCommentsSent}
|
||||
/>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* File tree sidebar */}
|
||||
<div className="w-[280px] min-w-[220px] border-r border-border flex-shrink-0 overflow-hidden">
|
||||
<ReviewFileTree
|
||||
files={allFiles}
|
||||
reviewedFiles={reviewedFiles}
|
||||
staleFiles={staleFiles}
|
||||
commentCountByFile={commentCountByFile}
|
||||
selectedFile={selectedFile}
|
||||
onSelectFile={handleSelectFile}
|
||||
onToggleReviewed={handleToggleReviewed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Diff list */}
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
{allFiles.length > 0 ? (
|
||||
<ReviewDiffList
|
||||
files={allFiles}
|
||||
reviewedFiles={reviewedFiles}
|
||||
staleFiles={staleFiles}
|
||||
sessionId={sessionId}
|
||||
autoMarkOnScroll={autoMarkOnScroll}
|
||||
wordWrap={wordWrap}
|
||||
onToggleReviewed={handleToggleReviewed}
|
||||
onDiscard={handleDiscard}
|
||||
fileRefs={fileRefs}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
No changes to review
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useEffect, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCopy,
|
||||
IconArrowBackUp,
|
||||
} from '@tabler/icons-react';
|
||||
import { Checkbox } from '@kandev/ui/checkbox';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { FileDiffViewer } from '@/components/diff';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import type { ReviewFile } from './types';
|
||||
|
||||
type ReviewDiffListProps = {
|
||||
files: ReviewFile[];
|
||||
reviewedFiles: Set<string>;
|
||||
staleFiles: Set<string>;
|
||||
sessionId: string;
|
||||
autoMarkOnScroll: boolean;
|
||||
wordWrap: boolean;
|
||||
onToggleReviewed: (path: string, reviewed: boolean) => void;
|
||||
onDiscard: (path: string) => void;
|
||||
fileRefs: Map<string, React.RefObject<HTMLDivElement | null>>;
|
||||
};
|
||||
|
||||
export const ReviewDiffList = memo(function ReviewDiffList({
|
||||
files,
|
||||
reviewedFiles,
|
||||
staleFiles,
|
||||
sessionId,
|
||||
autoMarkOnScroll,
|
||||
wordWrap,
|
||||
onToggleReviewed,
|
||||
onDiscard,
|
||||
fileRefs,
|
||||
}: ReviewDiffListProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<div ref={scrollContainerRef} className="overflow-y-auto h-full">
|
||||
{files.map((file) => (
|
||||
<FileDiffSection
|
||||
key={file.path}
|
||||
file={file}
|
||||
isReviewed={reviewedFiles.has(file.path) && !staleFiles.has(file.path)}
|
||||
isStale={staleFiles.has(file.path)}
|
||||
sessionId={sessionId}
|
||||
autoMarkOnScroll={autoMarkOnScroll}
|
||||
wordWrap={wordWrap}
|
||||
onToggleReviewed={onToggleReviewed}
|
||||
onDiscard={onDiscard}
|
||||
sectionRef={fileRefs.get(file.path)}
|
||||
scrollContainer={scrollContainerRef}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
type FileDiffSectionProps = {
|
||||
file: ReviewFile;
|
||||
isReviewed: boolean;
|
||||
isStale: boolean;
|
||||
sessionId: string;
|
||||
autoMarkOnScroll: boolean;
|
||||
wordWrap: boolean;
|
||||
onToggleReviewed: (path: string, reviewed: boolean) => void;
|
||||
onDiscard: (path: string) => void;
|
||||
sectionRef?: React.RefObject<HTMLDivElement | null>;
|
||||
scrollContainer: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
function FileDiffSection({
|
||||
file,
|
||||
isReviewed,
|
||||
isStale,
|
||||
sessionId,
|
||||
autoMarkOnScroll,
|
||||
wordWrap,
|
||||
onToggleReviewed,
|
||||
onDiscard,
|
||||
sectionRef,
|
||||
scrollContainer,
|
||||
}: FileDiffSectionProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
const autoMarkedRef = useRef(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Lazy rendering: mount the diff viewer only when section becomes visible
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px 0px', root: scrollContainer.current }
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [scrollContainer]);
|
||||
|
||||
// Auto-mark on scroll: when the file section leaves the top of the scroll container
|
||||
const scrollSentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoMarkOnScroll || isReviewed || isStale) {
|
||||
autoMarkedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const sentinel = scrollSentinelRef.current;
|
||||
const root = scrollContainer.current;
|
||||
if (!sentinel || !root) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
// File has scrolled above the scroll container (not intersecting + bounding rect is above root)
|
||||
if (!entry.isIntersecting && entry.boundingClientRect.top < root.getBoundingClientRect().top && !autoMarkedRef.current) {
|
||||
autoMarkedRef.current = true;
|
||||
console.debug('[review] auto-mark reviewed:', file.path);
|
||||
onToggleReviewed(file.path, true);
|
||||
}
|
||||
},
|
||||
{ threshold: 0, root }
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [autoMarkOnScroll, file.path, isReviewed, isStale, onToggleReviewed, scrollContainer]);
|
||||
|
||||
const handleCheckboxChange = useCallback(
|
||||
(checked: boolean | 'indeterminate') => {
|
||||
onToggleReviewed(file.path, checked === true);
|
||||
},
|
||||
[file.path, onToggleReviewed]
|
||||
);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
navigator.clipboard.writeText(file.path);
|
||||
toast({ title: 'Copied', description: file.path, variant: 'success' });
|
||||
}, [file.path, toast]);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
onDiscard(file.path);
|
||||
}, [file.path, onDiscard]);
|
||||
|
||||
return (
|
||||
<div ref={sectionRef} className="border-b border-border">
|
||||
{/* Sentinel for auto-mark-on-scroll */}
|
||||
<div ref={scrollSentinelRef} className="h-0" />
|
||||
|
||||
{/* Sticky file header */}
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 px-4 py-2 bg-card/95 backdrop-blur-sm border-b border-border/50">
|
||||
<Checkbox
|
||||
checked={isReviewed}
|
||||
onCheckedChange={handleCheckboxChange}
|
||||
className="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium truncate">{file.path}</span>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{isStale && (
|
||||
<span className="flex items-center gap-1 text-xs text-yellow-500">
|
||||
<IconAlertTriangle className="h-3.5 w-3.5" />
|
||||
changed
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{file.additions > 0 && <span className="text-emerald-500">+{file.additions}</span>}
|
||||
{file.additions > 0 && file.deletions > 0 && ' / '}
|
||||
{file.deletions > 0 && <span className="text-rose-500">-{file.deletions}</span>}
|
||||
</span>
|
||||
|
||||
{/* File actions */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Copy path */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"
|
||||
onClick={handleCopyPath}
|
||||
>
|
||||
<IconCopy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy file path</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Discard changes */}
|
||||
{file.source === 'uncommitted' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100 hover:text-destructive"
|
||||
onClick={handleDiscard}
|
||||
>
|
||||
<IconArrowBackUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Revert changes</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sentinel for lazy loading */}
|
||||
<div ref={sentinelRef} />
|
||||
|
||||
{/* Diff content (lazy rendered) */}
|
||||
{isVisible && file.diff ? (
|
||||
<div className="px-2 py-2">
|
||||
<FileDiffViewer
|
||||
filePath={file.path}
|
||||
diff={file.diff}
|
||||
status={file.status}
|
||||
enableComments
|
||||
sessionId={sessionId}
|
||||
hideHeader
|
||||
wordWrap={wordWrap}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||
Loading diff...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconMessage,
|
||||
IconAlertTriangle,
|
||||
} from '@tabler/icons-react';
|
||||
import { Checkbox } from '@kandev/ui/checkbox';
|
||||
import { cn } from '@kandev/ui/lib/utils';
|
||||
import { FileIcon } from '@/components/ui/file-icon';
|
||||
import { FileStatusIcon } from '@/components/task/file-status-icon';
|
||||
import type { ReviewFile, FileTreeNode } from './types';
|
||||
import { buildFileTree } from './types';
|
||||
|
||||
type ReviewFileTreeProps = {
|
||||
files: ReviewFile[];
|
||||
reviewedFiles: Set<string>;
|
||||
staleFiles: Set<string>;
|
||||
commentCountByFile: Record<string, number>;
|
||||
selectedFile: string | null;
|
||||
onSelectFile: (path: string) => void;
|
||||
onToggleReviewed: (path: string, reviewed: boolean) => void;
|
||||
};
|
||||
|
||||
export const ReviewFileTree = memo(function ReviewFileTree({
|
||||
files,
|
||||
reviewedFiles,
|
||||
staleFiles,
|
||||
commentCountByFile,
|
||||
selectedFile,
|
||||
onSelectFile,
|
||||
onToggleReviewed,
|
||||
}: ReviewFileTreeProps) {
|
||||
const tree = useMemo(() => buildFileTree(files), [files]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto text-sm">
|
||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wide border-b border-border">
|
||||
Files
|
||||
</div>
|
||||
<div className="py-1">
|
||||
{tree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.path}
|
||||
node={node}
|
||||
reviewedFiles={reviewedFiles}
|
||||
staleFiles={staleFiles}
|
||||
commentCountByFile={commentCountByFile}
|
||||
selectedFile={selectedFile}
|
||||
onSelectFile={onSelectFile}
|
||||
onToggleReviewed={onToggleReviewed}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
type TreeNodeProps = {
|
||||
node: FileTreeNode;
|
||||
reviewedFiles: Set<string>;
|
||||
staleFiles: Set<string>;
|
||||
commentCountByFile: Record<string, number>;
|
||||
selectedFile: string | null;
|
||||
onSelectFile: (path: string) => void;
|
||||
onToggleReviewed: (path: string, reviewed: boolean) => void;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
function TreeNode({
|
||||
node,
|
||||
reviewedFiles,
|
||||
staleFiles,
|
||||
commentCountByFile,
|
||||
selectedFile,
|
||||
onSelectFile,
|
||||
onToggleReviewed,
|
||||
depth,
|
||||
}: TreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
if (node.isDir) {
|
||||
// Calculate aggregate stats for directory
|
||||
const dirFiles = collectFiles(node);
|
||||
const dirReviewed = dirFiles.filter((f) => reviewedFiles.has(f.path) && !staleFiles.has(f.path)).length;
|
||||
const dirTotal = dirFiles.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center w-full gap-1 px-2 py-1 hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{expanded ? (
|
||||
<IconChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<IconChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground truncate">{node.name}</span>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground/60">
|
||||
{dirReviewed}/{dirTotal}
|
||||
</span>
|
||||
</button>
|
||||
{expanded && node.children && (
|
||||
<div>
|
||||
{node.children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.path}
|
||||
node={child}
|
||||
reviewedFiles={reviewedFiles}
|
||||
staleFiles={staleFiles}
|
||||
commentCountByFile={commentCountByFile}
|
||||
selectedFile={selectedFile}
|
||||
onSelectFile={onSelectFile}
|
||||
onToggleReviewed={onToggleReviewed}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// File node
|
||||
const file = node.file!;
|
||||
const isReviewed = reviewedFiles.has(file.path);
|
||||
const isStale = staleFiles.has(file.path);
|
||||
const commentCount = commentCountByFile[file.path] ?? 0;
|
||||
const isSelected = selectedFile === file.path;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 cursor-pointer transition-colors group',
|
||||
isSelected ? 'bg-accent/50' : 'hover:bg-muted/50'
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
onClick={() => onSelectFile(file.path)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isReviewed && !isStale}
|
||||
onCheckedChange={(checked) => {
|
||||
onToggleReviewed(file.path, checked === true);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<FileIcon fileName={node.name} className="h-4 w-4 shrink-0" />
|
||||
<span className="text-xs truncate flex-1">{node.name}</span>
|
||||
|
||||
{/* Changed badge (stale) */}
|
||||
{isStale && (
|
||||
<IconAlertTriangle className="h-3 w-3 text-yellow-500 shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Comment count */}
|
||||
{commentCount > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-blue-500">
|
||||
<IconMessage className="h-3 w-3" />
|
||||
{commentCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* File status */}
|
||||
<FileStatusIcon status={file.status as 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'} />
|
||||
|
||||
{/* +/- stats */}
|
||||
<span className="text-[10px] text-muted-foreground/60 whitespace-nowrap">
|
||||
{file.additions > 0 && <span className="text-emerald-500">+{file.additions}</span>}
|
||||
{file.additions > 0 && file.deletions > 0 && ' '}
|
||||
{file.deletions > 0 && <span className="text-rose-500">-{file.deletions}</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Collect all leaf files from a tree node recursively */
|
||||
function collectFiles(node: FileTreeNode): ReviewFile[] {
|
||||
if (!node.isDir) return node.file ? [node.file] : [];
|
||||
const result: ReviewFile[] = [];
|
||||
for (const child of node.children ?? []) {
|
||||
result.push(...collectFiles(child));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useCallback } from 'react';
|
||||
import {
|
||||
IconSettings,
|
||||
IconX,
|
||||
IconMessageForward,
|
||||
IconLayoutColumns,
|
||||
IconLayoutRows,
|
||||
IconTextWrap,
|
||||
} from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kandev/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { Checkbox } from '@kandev/ui/checkbox';
|
||||
import type { DiffComment } from '@/lib/diff/types';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { updateUserSettings } from '@/lib/api';
|
||||
import { VcsSplitButton } from '@/components/vcs-split-button';
|
||||
|
||||
type ReviewTopBarProps = {
|
||||
sessionId: string;
|
||||
reviewedCount: number;
|
||||
totalCount: number;
|
||||
commentCount: number;
|
||||
baseBranch?: string;
|
||||
taskTitle?: string;
|
||||
splitView: boolean;
|
||||
onToggleSplitView: (split: boolean) => void;
|
||||
wordWrap: boolean;
|
||||
onToggleWordWrap: (wrap: boolean) => void;
|
||||
onSendComments: (comments: DiffComment[]) => void;
|
||||
onClose: () => void;
|
||||
getPendingComments: () => DiffComment[];
|
||||
markCommentsSent: (ids: string[]) => void;
|
||||
};
|
||||
|
||||
export const ReviewTopBar = memo(function ReviewTopBar({
|
||||
sessionId,
|
||||
reviewedCount,
|
||||
totalCount,
|
||||
commentCount,
|
||||
baseBranch,
|
||||
taskTitle,
|
||||
splitView,
|
||||
onToggleSplitView,
|
||||
wordWrap,
|
||||
onToggleWordWrap,
|
||||
onSendComments,
|
||||
onClose,
|
||||
getPendingComments,
|
||||
markCommentsSent,
|
||||
}: ReviewTopBarProps) {
|
||||
const reviewAutoMarkOnScroll = useAppStore(
|
||||
(state) => state.userSettings.reviewAutoMarkOnScroll
|
||||
);
|
||||
const setUserSettings = useAppStore((state) => state.setUserSettings);
|
||||
const userSettings = useAppStore((state) => state.userSettings);
|
||||
|
||||
const progressPercent = totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0;
|
||||
|
||||
const handleFixComments = useCallback(() => {
|
||||
const comments = getPendingComments();
|
||||
if (comments.length === 0) return;
|
||||
onSendComments(comments);
|
||||
markCommentsSent(comments.map((c) => c.id));
|
||||
}, [getPendingComments, onSendComments, markCommentsSent]);
|
||||
|
||||
const handleToggleAutoMark = useCallback(
|
||||
(checked: boolean) => {
|
||||
const next = { ...userSettings, reviewAutoMarkOnScroll: checked };
|
||||
setUserSettings(next);
|
||||
|
||||
const client = getWebSocketClient();
|
||||
const payload = { review_auto_mark_on_scroll: checked };
|
||||
if (client) {
|
||||
client.request('user.settings.update', payload).catch(() => {
|
||||
updateUserSettings(payload, { cache: 'no-store' }).catch(() => {});
|
||||
});
|
||||
} else {
|
||||
updateUserSettings(payload, { cache: 'no-store' }).catch(() => {});
|
||||
}
|
||||
},
|
||||
[userSettings, setUserSettings]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-border bg-card/50 min-h-[48px]">
|
||||
{/* Settings cog */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="px-2 cursor-pointer">
|
||||
<IconSettings className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-2"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
handleToggleAutoMark(!reviewAutoMarkOnScroll);
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={reviewAutoMarkOnScroll}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<span className="text-sm flex-1">
|
||||
Auto-mark reviewed on scroll
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden max-w-[200px]">
|
||||
<div
|
||||
className="h-full bg-emerald-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{reviewedCount} of {totalCount} files reviewed
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Word wrap toggle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={`px-2 cursor-pointer ${wordWrap ? 'bg-muted' : ''}`}
|
||||
onClick={() => onToggleWordWrap(!wordWrap)}
|
||||
>
|
||||
<IconTextWrap className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle word wrap</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Split/Unified toggle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="px-2 cursor-pointer"
|
||||
onClick={() => onToggleSplitView(!splitView)}
|
||||
>
|
||||
{splitView ? (
|
||||
<IconLayoutRows className="h-4 w-4" />
|
||||
) : (
|
||||
<IconLayoutColumns className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{splitView ? 'Switch to unified view' : 'Switch to split view'}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Fix Comments button */}
|
||||
{commentCount > 0 && (
|
||||
<Button size="sm" variant="outline" className="cursor-pointer" onClick={handleFixComments}>
|
||||
<IconMessageForward className="h-4 w-4" />
|
||||
Fix Comments
|
||||
<span className="ml-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||
{commentCount}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<VcsSplitButton
|
||||
sessionId={sessionId}
|
||||
baseBranch={baseBranch}
|
||||
taskTitle={taskTitle}
|
||||
/>
|
||||
|
||||
{/* Close button */}
|
||||
<Button size="sm" variant="ghost" className="px-2 cursor-pointer" onClick={onClose}>
|
||||
<IconX className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
export type ReviewFile = {
|
||||
path: string;
|
||||
diff: string;
|
||||
status: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
staged: boolean;
|
||||
source: 'uncommitted' | 'committed';
|
||||
};
|
||||
|
||||
export type FileTreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
children?: FileTreeNode[];
|
||||
file?: ReviewFile;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize diff content by handling edge cases from the backend.
|
||||
* Handles concatenated diffs with "--- Staged changes ---" separator.
|
||||
*/
|
||||
export function normalizeDiffContent(diffContent: string): string {
|
||||
if (!diffContent || typeof diffContent !== 'string') return '';
|
||||
let trimmed = diffContent.trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
const stagedSeparator = '--- Staged changes ---';
|
||||
if (trimmed.includes(stagedSeparator)) {
|
||||
const parts = trimmed.split(stagedSeparator);
|
||||
trimmed = (parts[1] || parts[0]).trim();
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple djb2 hash of a string — fast, non-cryptographic.
|
||||
* Used to detect if a diff has changed since the user last reviewed it.
|
||||
*/
|
||||
export function hashDiff(content: string): string {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
hash = ((hash << 5) + hash + content.charCodeAt(i)) | 0;
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a hierarchical file tree from flat file paths.
|
||||
* Collapses single-child directories (e.g., `src/components` as one node if `src` has no other children).
|
||||
*/
|
||||
export function buildFileTree(files: ReviewFile[]): FileTreeNode[] {
|
||||
const root: FileTreeNode = { name: '', path: '', isDir: true, children: [] };
|
||||
|
||||
for (const file of files) {
|
||||
const parts = file.path.split('/');
|
||||
let current = root;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const isLast = i === parts.length - 1;
|
||||
const partPath = parts.slice(0, i + 1).join('/');
|
||||
|
||||
if (isLast) {
|
||||
current.children!.push({
|
||||
name: part,
|
||||
path: file.path,
|
||||
isDir: false,
|
||||
file,
|
||||
});
|
||||
} else {
|
||||
let child = current.children!.find((c) => c.isDir && c.name === part);
|
||||
if (!child) {
|
||||
child = { name: part, path: partPath, isDir: true, children: [] };
|
||||
current.children!.push(child);
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse single-child directories
|
||||
function collapse(node: FileTreeNode): FileTreeNode {
|
||||
if (!node.isDir || !node.children) return node;
|
||||
|
||||
node.children = node.children.map(collapse);
|
||||
|
||||
if (node.children.length === 1 && node.children[0].isDir && node.name !== '') {
|
||||
const child = node.children[0];
|
||||
return {
|
||||
...child,
|
||||
name: `${node.name}/${child.name}`,
|
||||
};
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
const collapsed = collapse(root);
|
||||
|
||||
// Sort: directories first, then files, alphabetically
|
||||
function sortTree(nodes: FileTreeNode[]): FileTreeNode[] {
|
||||
return nodes.sort((a, b) => {
|
||||
if (a.isDir && !b.isDir) return -1;
|
||||
if (!a.isDir && b.isDir) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
}).map((node) => {
|
||||
if (node.isDir && node.children) {
|
||||
return { ...node, children: sortTree(node.children) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
return sortTree(collapsed.children ?? []);
|
||||
}
|
||||
@@ -371,6 +371,7 @@ export function EditorsSettings() {
|
||||
defaultEditorId: response.settings.default_editor_id || null,
|
||||
enablePreviewOnClick: response.settings.enable_preview_on_click ?? false,
|
||||
chatSubmitKey: response.settings.chat_submit_key ?? 'cmd_enter',
|
||||
reviewAutoMarkOnScroll: response.settings.review_auto_mark_on_scroll ?? true,
|
||||
loaded: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,6 +138,17 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
}
|
||||
}, [leftTab, hasChanges]);
|
||||
|
||||
// Listen for external requests to switch to 'changes' tab
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (hasChanges) {
|
||||
setLeftTab('changes');
|
||||
}
|
||||
};
|
||||
window.addEventListener('switch-to-changes-tab', handler);
|
||||
return () => window.removeEventListener('switch-to-changes-tab', handler);
|
||||
}, [hasChanges]);
|
||||
|
||||
// Request changes state - triggers focus and tooltip on chat input
|
||||
const [showRequestChangesTooltip, setShowRequestChangesTooltip] = useState(false);
|
||||
|
||||
|
||||
@@ -1,30 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { IconArrowBackUp } from '@tabler/icons-react';
|
||||
import { Badge } from '@kandev/ui/badge';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { SessionPanelContent } from '@kandev/ui/pannel-session';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { memo, useMemo, useCallback, createRef, useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@kandev/ui/alert-dialog';
|
||||
IconSettings,
|
||||
IconTextWrap,
|
||||
IconLayoutColumns,
|
||||
IconLayoutRows,
|
||||
IconMessageForward,
|
||||
IconArrowsMaximize,
|
||||
} from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Checkbox } from '@kandev/ui/checkbox';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kandev/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status';
|
||||
import { useCumulativeDiff } from '@/hooks/domains/session/use-cumulative-diff';
|
||||
import { useGitOperations } from '@/hooks/use-git-operations';
|
||||
import { FileDiffViewer } from '@/components/diff';
|
||||
import type { FileInfo } from '@/lib/state/slices';
|
||||
import { useSessionFileReviews } from '@/hooks/use-session-file-reviews';
|
||||
import { useDiffCommentsStore } from '@/lib/state/slices/diff-comments/diff-comments-slice';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { updateUserSettings } from '@/lib/api';
|
||||
import { formatReviewCommentsAsMarkdown } from '@/components/task/chat/messages/review-comments-attachment';
|
||||
import { ReviewDiffList } from '@/components/review/review-diff-list';
|
||||
import { ReviewDialog } from '@/components/review/review-dialog';
|
||||
import type { ReviewFile } from '@/components/review/types';
|
||||
import { hashDiff, normalizeDiffContent } from '@/components/review/types';
|
||||
import type { DiffComment } from '@/lib/diff/types';
|
||||
import type { SelectedDiff } from './task-layout';
|
||||
import { FileStatusIcon } from './file-status-icon';
|
||||
|
||||
type TaskChangesPanelProps = {
|
||||
selectedDiff: SelectedDiff | null;
|
||||
@@ -33,277 +42,399 @@ type TaskChangesPanelProps = {
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize diff content by handling edge cases from the backend.
|
||||
*
|
||||
* Handles:
|
||||
* 1. Multiple diffs concatenated with "--- Staged changes ---" separator
|
||||
* 2. Diffs missing headers
|
||||
*/
|
||||
function normalizeDiffContent(diffContent: string): string {
|
||||
if (!diffContent || typeof diffContent !== 'string') return '';
|
||||
|
||||
let trimmed = diffContent.trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
// Handle concatenated diffs with "--- Staged changes ---" separator
|
||||
const stagedSeparator = '--- Staged changes ---';
|
||||
if (trimmed.includes(stagedSeparator)) {
|
||||
const parts = trimmed.split(stagedSeparator);
|
||||
trimmed = (parts[1] || parts[0]).trim();
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
selectedDiff,
|
||||
onClearSelected,
|
||||
onOpenFile,
|
||||
}: TaskChangesPanelProps) {
|
||||
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
|
||||
const [splitView, setSplitView] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return localStorage.getItem('diff-view-mode') === 'split';
|
||||
});
|
||||
const [wordWrap, setWordWrap] = useState(false);
|
||||
const [reviewDialogOpen, setReviewDialogOpen] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
const autoMarkOnScroll = useAppStore((s) => s.userSettings.reviewAutoMarkOnScroll);
|
||||
const setUserSettings = useAppStore((state) => state.setUserSettings);
|
||||
const userSettings = useAppStore((state) => state.userSettings);
|
||||
const taskTitle = useAppStore((state) => {
|
||||
if (!state.tasks.activeTaskId) return undefined;
|
||||
return state.kanban.tasks.find((t) => t.id === state.tasks.activeTaskId)?.title;
|
||||
});
|
||||
const baseBranch = useAppStore((state) => {
|
||||
if (!activeSessionId) return undefined;
|
||||
return state.taskSessions.items[activeSessionId]?.base_branch;
|
||||
});
|
||||
|
||||
const gitStatus = useSessionGitStatus(activeSessionId);
|
||||
const { discard, isLoading: isDiscarding } = useGitOperations(activeSessionId);
|
||||
// Fetch cumulative diff (all commits combined) when no specific file is selected
|
||||
const { diff: cumulativeDiff, loading: cumulativeLoading } = useCumulativeDiff(
|
||||
selectedDiff ? null : activeSessionId
|
||||
const { diff: cumulativeDiff, loading: cumulativeLoading } = useCumulativeDiff(activeSessionId);
|
||||
const { discard } = useGitOperations(activeSessionId);
|
||||
|
||||
const { reviews, markReviewed, markUnreviewed } = useSessionFileReviews(activeSessionId);
|
||||
|
||||
const bySession = useDiffCommentsStore((s) => s.bySession);
|
||||
const getPendingComments = useDiffCommentsStore((s) => s.getPendingComments);
|
||||
const markCommentsSent = useDiffCommentsStore((s) => s.markCommentsSent);
|
||||
|
||||
// Merge uncommitted + committed files into a single sorted list
|
||||
const allFiles = useMemo<ReviewFile[]>(() => {
|
||||
const fileMap = new Map<string, ReviewFile>();
|
||||
|
||||
// Uncommitted changes from git status
|
||||
if (gitStatus?.files) {
|
||||
for (const [path, file] of Object.entries(gitStatus.files)) {
|
||||
const diff = file.diff ? normalizeDiffContent(file.diff) : '';
|
||||
if (diff) {
|
||||
fileMap.set(path, {
|
||||
path,
|
||||
diff,
|
||||
status: file.status,
|
||||
additions: file.additions ?? 0,
|
||||
deletions: file.deletions ?? 0,
|
||||
staged: file.staged,
|
||||
source: 'uncommitted',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Committed changes from cumulative diff
|
||||
if (cumulativeDiff?.files) {
|
||||
for (const [path, file] of Object.entries(cumulativeDiff.files)) {
|
||||
if (fileMap.has(path)) continue; // Uncommitted takes priority
|
||||
const diff = file.diff ? normalizeDiffContent(file.diff) : '';
|
||||
if (diff) {
|
||||
fileMap.set(path, {
|
||||
path,
|
||||
diff,
|
||||
status: file.status || 'modified',
|
||||
additions: file.additions ?? 0,
|
||||
deletions: file.deletions ?? 0,
|
||||
staged: false,
|
||||
source: 'committed',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(fileMap.values()).sort((a, b) => a.path.localeCompare(b.path));
|
||||
}, [gitStatus, cumulativeDiff]);
|
||||
|
||||
// Reviewed / stale sets
|
||||
const { reviewedFiles, staleFiles } = useMemo(() => {
|
||||
const reviewed = new Set<string>();
|
||||
const stale = new Set<string>();
|
||||
|
||||
for (const file of allFiles) {
|
||||
const reviewState = reviews.get(file.path);
|
||||
if (!reviewState?.reviewed) continue;
|
||||
|
||||
const currentHash = hashDiff(file.diff);
|
||||
if (reviewState.diffHash && reviewState.diffHash !== currentHash) {
|
||||
stale.add(file.path);
|
||||
} else {
|
||||
reviewed.add(file.path);
|
||||
}
|
||||
}
|
||||
|
||||
return { reviewedFiles: reviewed, staleFiles: stale };
|
||||
}, [allFiles, reviews]);
|
||||
|
||||
// Comment counts
|
||||
const commentCountByFile = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
if (!activeSessionId) return counts;
|
||||
const sessionComments = bySession[activeSessionId];
|
||||
if (!sessionComments) return counts;
|
||||
|
||||
for (const [filePath, comments] of Object.entries(sessionComments)) {
|
||||
if (comments.length > 0) {
|
||||
counts[filePath] = comments.length;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [bySession, activeSessionId]);
|
||||
|
||||
const totalCommentCount = useMemo(() => {
|
||||
return Object.values(commentCountByFile).reduce((sum, c) => sum + c, 0);
|
||||
}, [commentCountByFile]);
|
||||
|
||||
// File refs for scrollIntoView
|
||||
const fileRefs = useMemo(() => {
|
||||
const refs = new Map<string, React.RefObject<HTMLDivElement | null>>();
|
||||
for (const file of allFiles) {
|
||||
refs.set(file.path, createRef<HTMLDivElement>());
|
||||
}
|
||||
return refs;
|
||||
}, [allFiles]);
|
||||
|
||||
// Scroll to selected file when selectedDiff changes
|
||||
const scrolledRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!selectedDiff?.path || scrolledRef.current === selectedDiff.path) return;
|
||||
scrolledRef.current = selectedDiff.path;
|
||||
|
||||
const ref = fileRefs.get(selectedDiff.path);
|
||||
if (ref?.current) {
|
||||
// Small delay to let lazy-rendered content mount
|
||||
requestAnimationFrame(() => {
|
||||
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
}
|
||||
onClearSelected();
|
||||
}, [selectedDiff, fileRefs, onClearSelected]);
|
||||
|
||||
// Reset scroll tracking when selectedDiff is cleared
|
||||
useEffect(() => {
|
||||
if (!selectedDiff) {
|
||||
scrolledRef.current = null;
|
||||
}
|
||||
}, [selectedDiff]);
|
||||
|
||||
const handleToggleSplitView = useCallback((split: boolean) => {
|
||||
setSplitView(split);
|
||||
const mode = split ? 'split' : 'unified';
|
||||
localStorage.setItem('diff-view-mode', mode);
|
||||
window.dispatchEvent(new CustomEvent('diff-view-mode-change', { detail: mode }));
|
||||
}, []);
|
||||
|
||||
const handleToggleReviewed = useCallback(
|
||||
(path: string, reviewed: boolean) => {
|
||||
if (reviewed) {
|
||||
const file = allFiles.find((f) => f.path === path);
|
||||
const diffHash = file ? hashDiff(file.diff) : '';
|
||||
markReviewed(path, diffHash);
|
||||
} else {
|
||||
markUnreviewed(path);
|
||||
}
|
||||
},
|
||||
[allFiles, markReviewed, markUnreviewed]
|
||||
);
|
||||
|
||||
const selectedDiffPath = selectedDiff?.path ?? null;
|
||||
// Use provided diff content (from commit or snapshot) or look up from current git status
|
||||
const hasProvidedContent = Boolean(selectedDiff?.content);
|
||||
|
||||
// Convert git status files to array for display (uncommitted changes)
|
||||
const uncommittedFiles = useMemo<FileInfo[]>(() => {
|
||||
if (!gitStatus?.files || Object.keys(gitStatus.files).length === 0) {
|
||||
return [];
|
||||
}
|
||||
return Object.values(gitStatus.files) as FileInfo[];
|
||||
}, [gitStatus]);
|
||||
|
||||
// Convert cumulative diff files to array (committed changes)
|
||||
const committedFiles = useMemo<Array<{ path: string; diff: string; additions: number; deletions: number }>>(() => {
|
||||
if (!cumulativeDiff?.files || Object.keys(cumulativeDiff.files).length === 0) {
|
||||
return [];
|
||||
}
|
||||
// Create a Set of uncommitted file paths for efficient lookup
|
||||
const uncommittedPaths = new Set(uncommittedFiles.map(f => f.path));
|
||||
|
||||
return Object.entries(cumulativeDiff.files)
|
||||
.filter(([path, file]) => file.diff && !uncommittedPaths.has(path)) // Exclude uncommitted files
|
||||
.map(([path, file]) => ({
|
||||
path,
|
||||
diff: file.diff!,
|
||||
additions: file.additions ?? 0,
|
||||
deletions: file.deletions ?? 0,
|
||||
}));
|
||||
}, [cumulativeDiff, uncommittedFiles]);
|
||||
|
||||
const selectedFile = selectedDiffPath && gitStatus?.files ? gitStatus.files[selectedDiffPath] : null;
|
||||
// Prioritize current git status for uncommitted files, use provided content only for historical diffs
|
||||
// This ensures that when a file is modified by the agent, we show the latest diff
|
||||
const selectedDiffContent = selectedFile?.diff ?? selectedDiff?.content ?? '';
|
||||
const isSingleDiffSelected = Boolean(selectedDiffPath && (hasProvidedContent || selectedFile));
|
||||
// Discard only works for uncommitted changes (not committed/historical diffs)
|
||||
const canDiscard = Boolean(selectedDiffPath && selectedFile && !hasProvidedContent);
|
||||
|
||||
const hasUncommitted = uncommittedFiles.length > 0;
|
||||
const hasCommitted = committedFiles.length > 0;
|
||||
const hasAnyChanges = hasUncommitted || hasCommitted;
|
||||
|
||||
const handleDiscardClick = () => {
|
||||
setShowDiscardDialog(true);
|
||||
};
|
||||
|
||||
const handleDiscardConfirm = async () => {
|
||||
if (!selectedDiffPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await discard([selectedDiffPath]);
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: 'Changes discarded',
|
||||
description: `Successfully discarded changes to ${selectedDiffPath}`,
|
||||
variant: 'success',
|
||||
});
|
||||
onClearSelected();
|
||||
} else {
|
||||
toast({
|
||||
title: 'Failed to discard changes',
|
||||
description: result.error || 'An unknown error occurred',
|
||||
variant: 'error',
|
||||
});
|
||||
const handleDiscard = useCallback(
|
||||
async (path: string) => {
|
||||
try {
|
||||
const result = await discard([path]);
|
||||
if (result.success) {
|
||||
toast({ title: 'Changes discarded', description: path, variant: 'success' });
|
||||
} else {
|
||||
toast({ title: 'Discard failed', description: result.error || 'An error occurred', variant: 'error' });
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ title: 'Discard failed', description: e instanceof Error ? e.message : 'An error occurred', variant: 'error' });
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to discard changes',
|
||||
description: error instanceof Error ? error.message : 'An unknown error occurred',
|
||||
variant: 'error',
|
||||
},
|
||||
[discard, toast]
|
||||
);
|
||||
|
||||
const handleToggleAutoMark = useCallback(
|
||||
(checked: boolean) => {
|
||||
const next = { ...userSettings, reviewAutoMarkOnScroll: checked };
|
||||
setUserSettings(next);
|
||||
|
||||
const client = getWebSocketClient();
|
||||
const payload = { review_auto_mark_on_scroll: checked };
|
||||
if (client) {
|
||||
client.request('user.settings.update', payload).catch(() => {
|
||||
updateUserSettings(payload, { cache: 'no-store' }).catch(() => { });
|
||||
});
|
||||
} else {
|
||||
updateUserSettings(payload, { cache: 'no-store' }).catch(() => { });
|
||||
}
|
||||
},
|
||||
[userSettings, setUserSettings]
|
||||
);
|
||||
|
||||
const handleFixComments = useCallback(() => {
|
||||
if (!activeSessionId || !activeTaskId) return;
|
||||
const comments = getPendingComments();
|
||||
if (comments.length === 0) return;
|
||||
|
||||
const markdown = formatReviewCommentsAsMarkdown(comments);
|
||||
if (!markdown) return;
|
||||
|
||||
const client = getWebSocketClient();
|
||||
if (client) {
|
||||
client.request('message.add', {
|
||||
task_id: activeTaskId,
|
||||
session_id: activeSessionId,
|
||||
content: markdown,
|
||||
}).catch(() => {
|
||||
toast({ title: 'Failed to send comments', variant: 'error' });
|
||||
});
|
||||
} finally {
|
||||
setShowDiscardDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
markCommentsSent(comments.map((c) => c.id));
|
||||
}, [activeSessionId, activeTaskId, getPendingComments, markCommentsSent, toast]);
|
||||
|
||||
const handleDialogSendComments = useCallback(
|
||||
(comments: DiffComment[]) => {
|
||||
if (!activeTaskId || !activeSessionId || comments.length === 0) return;
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
const markdown = formatReviewCommentsAsMarkdown(comments);
|
||||
client.request('message.add', {
|
||||
task_id: activeTaskId,
|
||||
session_id: activeSessionId,
|
||||
content: markdown,
|
||||
}, 10000).catch(() => {
|
||||
toast({ title: 'Failed to send comments', variant: 'error' });
|
||||
});
|
||||
setReviewDialogOpen(false);
|
||||
},
|
||||
[activeTaskId, activeSessionId, toast]
|
||||
);
|
||||
|
||||
const reviewedCount = reviewedFiles.size;
|
||||
const totalCount = allFiles.length;
|
||||
const progressPercent = totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{selectedDiffPath && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs cursor-pointer ml-auto"
|
||||
onClick={onClearSelected}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Show all changes</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs cursor-pointer"
|
||||
disabled={!canDiscard || isDiscarding}
|
||||
onClick={handleDiscardClick}
|
||||
>
|
||||
<IconArrowBackUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{canDiscard ? 'Discard changes' : hasProvidedContent ? 'Cannot discard committed changes' : 'Select an uncommitted file'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-card/50 min-h-[36px]">
|
||||
{/* Settings cog */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="px-1.5 h-7 cursor-pointer">
|
||||
<IconSettings className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-2"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
handleToggleAutoMark(!autoMarkOnScroll);
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={autoMarkOnScroll}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<span className="text-sm flex-1">Auto-mark reviewed on scroll</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Progress bar */}
|
||||
{totalCount > 0 && (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-20 h-1 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-indigo-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
|
||||
{reviewedCount}/{totalCount} Reviewed
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Expand to review dialog */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="px-1.5 h-7 cursor-pointer"
|
||||
onClick={() => setReviewDialogOpen(true)}
|
||||
>
|
||||
<IconArrowsMaximize className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Expand review</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Word wrap */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={`px-1.5 h-7 cursor-pointer ${wordWrap ? 'bg-muted' : ''}`}
|
||||
onClick={() => setWordWrap(!wordWrap)}
|
||||
>
|
||||
<IconTextWrap className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle word wrap</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Split/Unified */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="px-1.5 h-7 cursor-pointer"
|
||||
onClick={() => handleToggleSplitView(!splitView)}
|
||||
>
|
||||
{splitView ? (
|
||||
<IconLayoutRows className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<IconLayoutColumns className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{splitView ? 'Unified view' : 'Split view'}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Fix Comments */}
|
||||
{totalCommentCount > 0 && (
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs cursor-pointer" onClick={handleFixComments}>
|
||||
<IconMessageForward className="h-3.5 w-3.5" />
|
||||
Fix
|
||||
<span className="ml-0.5 rounded-full bg-blue-500/30 px-1 py-0 text-[10px] font-medium text-blue-600 dark:text-blue-400">
|
||||
{totalCommentCount}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Diff content */}
|
||||
<SessionPanelContent>
|
||||
{isSingleDiffSelected && selectedDiffContent ? (
|
||||
// Render single file diff (from commit, snapshot, or uncommitted)
|
||||
// Use timestamp from git status to ensure re-render when file changes
|
||||
<div key={`selected-${selectedDiffPath}-${gitStatus?.timestamp ?? ''}-${selectedDiffContent.length}`} className="space-y-4">
|
||||
<FileDiffViewer
|
||||
filePath={selectedDiffPath!}
|
||||
diff={normalizeDiffContent(selectedDiffContent)}
|
||||
status="M"
|
||||
enableComments={!!activeSessionId}
|
||||
sessionId={activeSessionId ?? undefined}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</div>
|
||||
) : cumulativeLoading ? (
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{cumulativeLoading && allFiles.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Loading changes...
|
||||
</div>
|
||||
) : !hasAnyChanges ? (
|
||||
) : allFiles.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
No changes
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Uncommitted changes section */}
|
||||
{hasUncommitted && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Uncommitted ({uncommittedFiles.length})
|
||||
</div>
|
||||
{uncommittedFiles.map((file) => {
|
||||
if (!file.diff) return null;
|
||||
const normalizedDiff = normalizeDiffContent(file.diff);
|
||||
const diffKey = `uncommitted-${file.path}-${normalizedDiff.length}`;
|
||||
return (
|
||||
<div key={diffKey} className="space-y-2">
|
||||
<FileDiffViewer
|
||||
filePath={file.path}
|
||||
diff={normalizedDiff}
|
||||
status={file.status}
|
||||
enableComments={!!activeSessionId}
|
||||
sessionId={activeSessionId ?? undefined}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
) : activeSessionId ? (
|
||||
<ReviewDiffList
|
||||
files={allFiles}
|
||||
reviewedFiles={reviewedFiles}
|
||||
staleFiles={staleFiles}
|
||||
sessionId={activeSessionId}
|
||||
autoMarkOnScroll={autoMarkOnScroll}
|
||||
wordWrap={wordWrap}
|
||||
onToggleReviewed={handleToggleReviewed}
|
||||
onDiscard={handleDiscard}
|
||||
fileRefs={fileRefs}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Committed changes section */}
|
||||
{hasCommitted && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Committed ({committedFiles.length})
|
||||
</div>
|
||||
{committedFiles.map((file) => {
|
||||
if (!file.diff) return null;
|
||||
const normalizedDiff = normalizeDiffContent(file.diff);
|
||||
const diffKey = `committed-${file.path}-${normalizedDiff.length}`;
|
||||
return (
|
||||
<div key={diffKey} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileStatusIcon status="modified" />
|
||||
<Badge variant="secondary" className="rounded-full text-xs">
|
||||
{file.path}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-emerald-500">+{file.additions}</span>
|
||||
{' / '}
|
||||
<span className="text-rose-500">-{file.deletions}</span>
|
||||
</span>
|
||||
</div>
|
||||
<FileDiffViewer
|
||||
filePath={file.path}
|
||||
diff={normalizedDiff}
|
||||
status="M"
|
||||
enableComments={!!activeSessionId}
|
||||
sessionId={activeSessionId ?? undefined}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SessionPanelContent>
|
||||
|
||||
{/* Discard confirmation dialog */}
|
||||
<AlertDialog open={showDiscardDialog} onOpenChange={setShowDiscardDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Discard changes?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently discard all changes to{' '}
|
||||
<span className="font-semibold">{selectedDiffPath}</span>. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDiscardConfirm} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Discard
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{/* Review Dialog */}
|
||||
{activeSessionId && (
|
||||
<ReviewDialog
|
||||
open={reviewDialogOpen}
|
||||
onOpenChange={setReviewDialogOpen}
|
||||
sessionId={activeSessionId}
|
||||
baseBranch={baseBranch}
|
||||
taskTitle={taskTitle}
|
||||
onSendComments={handleDialogSendComments}
|
||||
gitStatusFiles={gitStatus?.files ?? null}
|
||||
cumulativeDiff={cumulativeDiff}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -33,6 +33,9 @@ import { useOpenSessionInEditor } from '@/hooks/use-open-session-in-editor';
|
||||
import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status';
|
||||
import { useSessionCommits } from '@/hooks/domains/session/use-session-commits';
|
||||
import { useGitOperations } from '@/hooks/use-git-operations';
|
||||
import { useSessionFileReviews } from '@/hooks/use-session-file-reviews';
|
||||
import { useCumulativeDiff } from '@/hooks/domains/session/use-cumulative-diff';
|
||||
import { hashDiff, normalizeDiffContent } from '@/components/review/types';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { deleteFile } from '@/lib/ws/workspace-files';
|
||||
import type { FileInfo } from '@/lib/state/store';
|
||||
@@ -76,6 +79,8 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile,
|
||||
const openEditor = useOpenSessionInEditor(activeSessionId ?? null);
|
||||
const gitOps = useGitOperations(activeSessionId ?? null);
|
||||
const { toast } = useToast();
|
||||
const { reviews } = useSessionFileReviews(activeSessionId);
|
||||
const { diff: cumulativeDiff } = useCumulativeDiff(activeSessionId);
|
||||
|
||||
// Track if we've initialized the tab for this session
|
||||
const hasInitializedTabRef = useRef<string | null>(null);
|
||||
@@ -196,6 +201,52 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile,
|
||||
}));
|
||||
}, [gitStatus]);
|
||||
|
||||
// Compute review progress across all files (uncommitted + committed)
|
||||
const { reviewedCount, totalFileCount } = useMemo(() => {
|
||||
const paths = new Set<string>();
|
||||
|
||||
// Uncommitted
|
||||
if (gitStatus?.files) {
|
||||
for (const [path, file] of Object.entries(gitStatus.files)) {
|
||||
const diff = file.diff ? normalizeDiffContent(file.diff) : '';
|
||||
if (diff) paths.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Committed (skip duplicates)
|
||||
if (cumulativeDiff?.files) {
|
||||
for (const [path, file] of Object.entries(cumulativeDiff.files)) {
|
||||
if (paths.has(path)) continue;
|
||||
const diff = file.diff ? normalizeDiffContent(file.diff) : '';
|
||||
if (diff) paths.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
let reviewed = 0;
|
||||
for (const path of paths) {
|
||||
const state = reviews.get(path);
|
||||
if (!state?.reviewed) continue;
|
||||
|
||||
// Check staleness: find the diff content for this path
|
||||
let diffContent = '';
|
||||
const uncommittedFile = gitStatus?.files?.[path];
|
||||
if (uncommittedFile?.diff) {
|
||||
diffContent = normalizeDiffContent(uncommittedFile.diff);
|
||||
} else if (cumulativeDiff?.files?.[path]?.diff) {
|
||||
diffContent = normalizeDiffContent(cumulativeDiff.files[path].diff!);
|
||||
}
|
||||
|
||||
if (diffContent && state.diffHash && state.diffHash !== hashDiff(diffContent)) {
|
||||
continue; // stale — don't count
|
||||
}
|
||||
reviewed++;
|
||||
}
|
||||
|
||||
return { reviewedCount: reviewed, totalFileCount: paths.size };
|
||||
}, [gitStatus, cumulativeDiff, reviews]);
|
||||
|
||||
const reviewProgressPercent = totalFileCount > 0 ? (reviewedCount / totalFileCount) * 100 : 0;
|
||||
|
||||
// Clear pending stage/unstage spinners when git status updates
|
||||
useEffect(() => {
|
||||
if (pendingStageFiles.size > 0) {
|
||||
@@ -303,7 +354,7 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile,
|
||||
rightContent={tabRightContent}
|
||||
>
|
||||
<TabsContent value="diff" className="flex-1 min-h-0">
|
||||
<SessionPanelContent>
|
||||
<SessionPanelContent className="flex flex-col">
|
||||
<div className="space-y-4">
|
||||
{/* Uncommitted changes section */}
|
||||
{changedFiles.length > 0 && (
|
||||
@@ -478,10 +529,34 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile,
|
||||
{/* Empty state */}
|
||||
{changedFiles.length === 0 && commits.length === 0 && (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
|
||||
No changes detected
|
||||
Your changed files will appear here
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Review progress — pushed to bottom of panel */}
|
||||
{totalFileCount > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="mt-auto flex items-center gap-2 pt-2 cursor-pointer transition-colors"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('switch-to-changes-tab'))}
|
||||
>
|
||||
<div className="flex-1 h-0.5 rounded-full bg-muted-foreground/10 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-muted-foreground/25 rounded-full transition-all duration-300"
|
||||
style={{ width: `${reviewProgressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground/40 whitespace-nowrap">
|
||||
{reviewedCount}/{totalFileCount} reviewed
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{reviewedCount} of {totalFileCount} files reviewed
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</SessionPanelContent>
|
||||
</TabsContent>
|
||||
<TabsContent value="files" className="flex-1 min-h-0">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useState, useCallback } from 'react';
|
||||
import { memo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
@@ -9,50 +9,25 @@ import {
|
||||
IconCopy,
|
||||
IconDots,
|
||||
IconGitBranch,
|
||||
IconGitMerge,
|
||||
IconGitPullRequest,
|
||||
IconChevronDown,
|
||||
IconCheck,
|
||||
IconLoader2,
|
||||
IconCloudDownload,
|
||||
IconCloudUpload,
|
||||
IconGitCherryPick,
|
||||
IconGitCommit,
|
||||
IconAlertTriangle,
|
||||
IconSettings,
|
||||
} from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from '@kandev/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from '@kandev/ui/dropdown-menu';
|
||||
import { Textarea } from '@kandev/ui/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@kandev/ui/popover';
|
||||
import { Checkbox } from '@kandev/ui/checkbox';
|
||||
import { Label } from '@kandev/ui/label';
|
||||
import { CommitStatBadge } from '@/components/diff-stat';
|
||||
import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status';
|
||||
import { useGitOperations } from '@/hooks/use-git-operations';
|
||||
import type { FileInfo } from '@/lib/state/slices';
|
||||
import { formatUserHomePath } from '@/lib/utils';
|
||||
import { EditorsMenu } from '@/components/task/editors-menu';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import { PreviewControls } from '@/components/task/preview/preview-controls';
|
||||
import { VcsSplitButton } from '@/components/vcs-split-button';
|
||||
import { DEBUG_UI } from '@/lib/config';
|
||||
|
||||
type TaskTopBarProps = {
|
||||
@@ -90,142 +65,13 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [copiedRepo, setCopiedRepo] = useState(false);
|
||||
const [copiedWorktree, setCopiedWorktree] = useState(false);
|
||||
const [commitDialogOpen, setCommitDialogOpen] = useState(false);
|
||||
const [commitMessage, setCommitMessage] = useState('');
|
||||
const [stageAll, setStageAll] = useState(true);
|
||||
const [prDialogOpen, setPrDialogOpen] = useState(false);
|
||||
const [prTitle, setPrTitle] = useState('');
|
||||
const [prBody, setPrBody] = useState('');
|
||||
const [prDraft, setPrDraft] = useState(true);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const gitStatus = useSessionGitStatus(activeSessionId ?? null);
|
||||
const { pull, push, rebase, merge, commit, createPR, isLoading: isGitLoading } = useGitOperations(activeSessionId ?? null);
|
||||
|
||||
// Use worktree branch if available, otherwise fall back to base branch
|
||||
const displayBranch = worktreeBranch || baseBranch;
|
||||
|
||||
// Check if the remote branch actually tracks the current branch (not just origin/main as base)
|
||||
// Pull/Push badges should only show when remote_branch is origin/<current_branch>
|
||||
const currentBranch = gitStatus?.branch;
|
||||
const remoteBranch = gitStatus?.remote_branch;
|
||||
const hasMatchingUpstream = remoteBranch && currentBranch && remoteBranch === `origin/${currentBranch}`;
|
||||
|
||||
// Calculate total additions and deletions from uncommitted files.
|
||||
let uncommittedAdditions = 0;
|
||||
let uncommittedDeletions = 0;
|
||||
if (gitStatus?.files && Object.keys(gitStatus.files).length > 0) {
|
||||
for (const file of Object.values(gitStatus.files) as FileInfo[]) {
|
||||
uncommittedAdditions += file.additions || 0;
|
||||
uncommittedDeletions += file.deletions || 0;
|
||||
}
|
||||
}
|
||||
|
||||
const handleGitOperation = useCallback(async (
|
||||
operation: () => Promise<{ success: boolean; output: string; error?: string; conflict_files?: string[] }>,
|
||||
operationName: string
|
||||
) => {
|
||||
try {
|
||||
const result = await operation();
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: `${operationName} successful`,
|
||||
description: result.output.slice(0, 200) || `${operationName} completed successfully`,
|
||||
variant: 'success',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: `${operationName} failed`,
|
||||
description: result.error || 'An error occurred',
|
||||
variant: 'error',
|
||||
});
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: `${operationName} failed`,
|
||||
description: error instanceof Error ? error.message : 'An unexpected error occurred',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
const handlePull = useCallback(() => {
|
||||
handleGitOperation(() => pull(), 'Pull');
|
||||
}, [handleGitOperation, pull]);
|
||||
|
||||
const handlePush = useCallback((force = false) => {
|
||||
handleGitOperation(() => push({ force }), force ? 'Force Push' : 'Push');
|
||||
}, [handleGitOperation, push]);
|
||||
|
||||
const handleRebase = useCallback(() => {
|
||||
// Rebase onto the base branch (e.g., main)
|
||||
// Backend expects just the branch name without 'origin/' prefix
|
||||
const targetBranch = baseBranch?.replace(/^origin\//, '') || 'main';
|
||||
handleGitOperation(() => rebase(targetBranch), 'Rebase');
|
||||
}, [handleGitOperation, rebase, baseBranch]);
|
||||
|
||||
const handleMerge = useCallback(() => {
|
||||
// Merge from the base branch (e.g., main)
|
||||
// Backend expects just the branch name without 'origin/' prefix
|
||||
const targetBranch = baseBranch?.replace(/^origin\//, '') || 'main';
|
||||
handleGitOperation(() => merge(targetBranch), 'Merge');
|
||||
}, [handleGitOperation, merge, baseBranch]);
|
||||
|
||||
const handleOpenCommitDialog = useCallback(() => {
|
||||
setCommitMessage('');
|
||||
setStageAll(true);
|
||||
setCommitDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCommit = useCallback(async () => {
|
||||
if (!commitMessage.trim()) return;
|
||||
setCommitDialogOpen(false);
|
||||
await handleGitOperation(() => commit(commitMessage.trim(), stageAll), 'Commit');
|
||||
setCommitMessage('');
|
||||
}, [commitMessage, stageAll, handleGitOperation, commit]);
|
||||
|
||||
const handleOpenPRDialog = useCallback(() => {
|
||||
setPrTitle(taskTitle || '');
|
||||
setPrBody('');
|
||||
setPrDialogOpen(true);
|
||||
}, [taskTitle]);
|
||||
|
||||
const handleCreatePR = useCallback(async () => {
|
||||
if (!prTitle.trim()) return;
|
||||
setPrDialogOpen(false);
|
||||
try {
|
||||
const result = await createPR(prTitle.trim(), prBody.trim(), baseBranch, prDraft);
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: prDraft ? 'Draft Pull Request created' : 'Pull Request created',
|
||||
description: result.pr_url || 'PR created successfully',
|
||||
variant: 'success',
|
||||
});
|
||||
// Open the PR URL in a new tab
|
||||
if (result.pr_url) {
|
||||
window.open(result.pr_url, '_blank');
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: 'Create PR failed',
|
||||
description: result.error || 'An error occurred',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Create PR failed',
|
||||
description: e instanceof Error ? e.message : 'An error occurred',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
setPrTitle('');
|
||||
setPrBody('');
|
||||
}, [prTitle, prBody, baseBranch, prDraft, createPR, toast]);
|
||||
|
||||
const handleBranchClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (displayBranch) {
|
||||
@@ -400,125 +246,12 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
<PreviewControls activeSessionId={activeSessionId ?? null} hasDevScript={hasDevScript} />
|
||||
<EditorsMenu activeSessionId={activeSessionId ?? null} />
|
||||
|
||||
{/* Commit Split Button */}
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={`rounded-none border-0 cursor-pointer ${
|
||||
gitStatus?.files && Object.keys(gitStatus.files).length > 0
|
||||
? 'bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-500/20'
|
||||
: ''
|
||||
}`}
|
||||
onClick={handleOpenCommitDialog}
|
||||
disabled={isGitLoading || !activeSessionId}
|
||||
>
|
||||
<IconGitCommit className={`h-4 w-4 ${gitStatus?.files && Object.keys(gitStatus.files).length > 0 ? 'text-amber-500' : ''}`} />
|
||||
Commit
|
||||
{gitStatus?.files && Object.keys(gitStatus.files).length > 0 && (
|
||||
<span className="ml-1 rounded-full bg-amber-500/20 px-1.5 py-0.5 text-xs font-medium">
|
||||
{Object.keys(gitStatus.files).length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{gitStatus?.files && Object.keys(gitStatus.files).length > 0
|
||||
? `Commit ${Object.keys(gitStatus.files).length} changed file${Object.keys(gitStatus.files).length !== 1 ? 's' : ''}`
|
||||
: 'No changes to commit'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="rounded-none border-0 border-l px-2 cursor-pointer" disabled={isGitLoading}>
|
||||
{isGitLoading ? (
|
||||
<IconLoader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<IconChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-3"
|
||||
onClick={handleOpenPRDialog}
|
||||
disabled={isGitLoading || !activeSessionId}
|
||||
>
|
||||
<IconGitPullRequest className="h-4 w-4 text-cyan-500" />
|
||||
<span className="flex-1">Create PR</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-3"
|
||||
onClick={handlePull}
|
||||
disabled={isGitLoading || !activeSessionId}
|
||||
>
|
||||
<IconCloudDownload className="h-4 w-4 text-blue-500" />
|
||||
<span className="flex-1">Pull</span>
|
||||
{/* Only show pull count when upstream matches current branch (origin/<branch>) */}
|
||||
{hasMatchingUpstream && (gitStatus?.behind ?? 0) > 0 && (
|
||||
<span className="rounded-full bg-blue-500/10 px-2 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||
↓{gitStatus?.behind}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
className="cursor-pointer gap-3"
|
||||
disabled={isGitLoading || !activeSessionId}
|
||||
>
|
||||
<IconCloudUpload className="h-4 w-4 text-green-500" />
|
||||
<span className="flex-1">Push</span>
|
||||
{/* Only show push count when upstream matches current branch (origin/<branch>) */}
|
||||
{hasMatchingUpstream && (gitStatus?.ahead ?? 0) > 0 && (
|
||||
<span className="rounded-full bg-green-500/10 px-2 py-0.5 text-xs font-medium text-green-600 dark:text-green-400">
|
||||
↑{gitStatus?.ahead}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-3"
|
||||
onClick={() => handlePush(false)}
|
||||
disabled={isGitLoading || !activeSessionId}
|
||||
>
|
||||
<IconCloudUpload className="h-4 w-4 text-green-500" />
|
||||
<span>Push</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-3"
|
||||
onClick={() => handlePush(true)}
|
||||
disabled={isGitLoading || !activeSessionId}
|
||||
>
|
||||
<IconAlertTriangle className="h-4 w-4 text-orange-500" />
|
||||
<span>Force Push</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-3"
|
||||
onClick={handleRebase}
|
||||
disabled={isGitLoading || !activeSessionId}
|
||||
>
|
||||
<IconGitCherryPick className="h-4 w-4 text-orange-500" />
|
||||
<span className="flex-1">Rebase</span>
|
||||
<span className="text-xs text-muted-foreground">onto {baseBranch || 'origin/main'}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-3"
|
||||
onClick={handleMerge}
|
||||
disabled={isGitLoading || !activeSessionId}
|
||||
>
|
||||
<IconGitMerge className="h-4 w-4 text-purple-500" />
|
||||
<span className="flex-1">Merge</span>
|
||||
<span className="text-xs text-muted-foreground">from {baseBranch || 'origin/main'}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<VcsSplitButton
|
||||
sessionId={activeSessionId ?? null}
|
||||
baseBranch={baseBranch}
|
||||
taskTitle={taskTitle}
|
||||
displayBranch={displayBranch}
|
||||
/>
|
||||
|
||||
{/* More Options (3-dot) Dropdown */}
|
||||
<DropdownMenu open={moreMenuOpen} onOpenChange={setMoreMenuOpen}>
|
||||
@@ -552,158 +285,6 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Commit Dialog */}
|
||||
<Dialog open={commitDialogOpen} onOpenChange={setCommitDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<IconGitCommit className="h-5 w-5 text-amber-500" />
|
||||
Commit Changes
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{gitStatus?.files && Object.keys(gitStatus.files).length > 0 ? (
|
||||
<span>
|
||||
<span className="font-medium text-foreground">{Object.keys(gitStatus.files).length}</span> file{Object.keys(gitStatus.files).length !== 1 ? 's' : ''} changed
|
||||
{(uncommittedAdditions > 0 || uncommittedDeletions > 0) && (
|
||||
<span className="ml-2">
|
||||
(<span className="text-green-600">+{uncommittedAdditions}</span>
|
||||
{' / '}
|
||||
<span className="text-red-600">-{uncommittedDeletions}</span>)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>No changes to commit</span>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="Enter commit message..."
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="stage-all"
|
||||
checked={stageAll}
|
||||
onCheckedChange={(checked) => setStageAll(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="stage-all" className="text-sm text-muted-foreground cursor-pointer">
|
||||
Stage all changes before committing
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={handleCommit}
|
||||
disabled={!commitMessage.trim() || isGitLoading}
|
||||
>
|
||||
{isGitLoading ? (
|
||||
<>
|
||||
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Committing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconCheck className="h-4 w-4 mr-2" />
|
||||
Commit
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Create PR Dialog */}
|
||||
<Dialog open={prDialogOpen} onOpenChange={setPrDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<IconGitPullRequest className="h-5 w-5 text-cyan-500" />
|
||||
Create Pull Request
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{baseBranch ? (
|
||||
<span>
|
||||
Creating PR from <span className="font-medium text-foreground">{displayBranch}</span> → <span className="font-medium text-foreground">{baseBranch}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Creating PR from <span className="font-medium text-foreground">{displayBranch}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pr-title" className="text-sm">Title</Label>
|
||||
<input
|
||||
id="pr-title"
|
||||
type="text"
|
||||
placeholder="Pull request title..."
|
||||
value={prTitle}
|
||||
onChange={(e) => setPrTitle(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pr-body" className="text-sm">Description</Label>
|
||||
<Textarea
|
||||
id="pr-body"
|
||||
placeholder="Describe your changes..."
|
||||
value={prBody}
|
||||
onChange={(e) => setPrBody(e.target.value)}
|
||||
rows={6}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="pr-draft"
|
||||
checked={prDraft}
|
||||
onCheckedChange={(checked) => setPrDraft(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="pr-draft" className="text-sm cursor-pointer">
|
||||
Create as draft
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={handleCreatePR}
|
||||
disabled={!prTitle.trim() || isGitLoading}
|
||||
className="bg-cyan-600 hover:bg-cyan-700 text-white"
|
||||
>
|
||||
{isGitLoading ? (
|
||||
<>
|
||||
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconGitPullRequest className="h-4 w-4 mr-2" />
|
||||
Create PR
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</header>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useState, useCallback } from 'react';
|
||||
import {
|
||||
IconGitCommit,
|
||||
IconGitPullRequest,
|
||||
IconGitMerge,
|
||||
IconGitCherryPick,
|
||||
IconLoader2,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconCloudDownload,
|
||||
IconCloudUpload,
|
||||
IconAlertTriangle,
|
||||
} from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from '@kandev/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from '@kandev/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { Checkbox } from '@kandev/ui/checkbox';
|
||||
import { Label } from '@kandev/ui/label';
|
||||
import { Textarea } from '@kandev/ui/textarea';
|
||||
import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status';
|
||||
import { useGitOperations } from '@/hooks/use-git-operations';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import type { FileInfo } from '@/lib/state/slices';
|
||||
|
||||
type VcsSplitButtonProps = {
|
||||
sessionId: string | null;
|
||||
baseBranch?: string;
|
||||
taskTitle?: string;
|
||||
/** Branch name shown in the PR dialog (e.g. "feature/foo → main") */
|
||||
displayBranch?: string | null;
|
||||
};
|
||||
|
||||
const VcsSplitButton = memo(function VcsSplitButton({
|
||||
sessionId,
|
||||
baseBranch,
|
||||
taskTitle,
|
||||
displayBranch,
|
||||
}: VcsSplitButtonProps) {
|
||||
const [commitDialogOpen, setCommitDialogOpen] = useState(false);
|
||||
const [commitMessage, setCommitMessage] = useState('');
|
||||
const [stageAll, setStageAll] = useState(true);
|
||||
const [prDialogOpen, setPrDialogOpen] = useState(false);
|
||||
const [prTitle, setPrTitle] = useState('');
|
||||
const [prBody, setPrBody] = useState('');
|
||||
const [prDraft, setPrDraft] = useState(true);
|
||||
|
||||
const { toast } = useToast();
|
||||
const gitStatus = useSessionGitStatus(sessionId);
|
||||
const { pull, push, rebase, merge, commit, createPR, isLoading: isGitLoading } =
|
||||
useGitOperations(sessionId);
|
||||
|
||||
// Check if remote branch tracks current branch
|
||||
const currentBranch = gitStatus?.branch;
|
||||
const remoteBranch = gitStatus?.remote_branch;
|
||||
const hasMatchingUpstream =
|
||||
remoteBranch && currentBranch && remoteBranch === `origin/${currentBranch}`;
|
||||
|
||||
// Uncommitted file stats
|
||||
const uncommittedFileCount = gitStatus?.files
|
||||
? Object.keys(gitStatus.files).length
|
||||
: 0;
|
||||
|
||||
let uncommittedAdditions = 0;
|
||||
let uncommittedDeletions = 0;
|
||||
if (gitStatus?.files && uncommittedFileCount > 0) {
|
||||
for (const file of Object.values(gitStatus.files) as FileInfo[]) {
|
||||
uncommittedAdditions += file.additions || 0;
|
||||
uncommittedDeletions += file.deletions || 0;
|
||||
}
|
||||
}
|
||||
|
||||
const aheadCount = gitStatus?.ahead ?? 0;
|
||||
const behindCount = gitStatus?.behind ?? 0;
|
||||
|
||||
// --- handlers ---
|
||||
|
||||
const handleGitOperation = useCallback(
|
||||
async (
|
||||
operation: () => Promise<{
|
||||
success: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
}>,
|
||||
operationName: string
|
||||
) => {
|
||||
try {
|
||||
const result = await operation();
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: `${operationName} successful`,
|
||||
description:
|
||||
result.output.slice(0, 200) || `${operationName} completed`,
|
||||
variant: 'success',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: `${operationName} failed`,
|
||||
description: result.error || 'An error occurred',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: `${operationName} failed`,
|
||||
description:
|
||||
e instanceof Error ? e.message : 'An unexpected error occurred',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
const handlePull = useCallback(() => {
|
||||
handleGitOperation(() => pull(), 'Pull');
|
||||
}, [handleGitOperation, pull]);
|
||||
|
||||
const handlePush = useCallback(
|
||||
(force = false) => {
|
||||
handleGitOperation(
|
||||
() => push({ force }),
|
||||
force ? 'Force Push' : 'Push'
|
||||
);
|
||||
},
|
||||
[handleGitOperation, push]
|
||||
);
|
||||
|
||||
const handleRebase = useCallback(() => {
|
||||
const targetBranch = baseBranch?.replace(/^origin\//, '') || 'main';
|
||||
handleGitOperation(() => rebase(targetBranch), 'Rebase');
|
||||
}, [handleGitOperation, rebase, baseBranch]);
|
||||
|
||||
const handleMerge = useCallback(() => {
|
||||
const targetBranch = baseBranch?.replace(/^origin\//, '') || 'main';
|
||||
handleGitOperation(() => merge(targetBranch), 'Merge');
|
||||
}, [handleGitOperation, merge, baseBranch]);
|
||||
|
||||
const handleOpenCommitDialog = useCallback(() => {
|
||||
setCommitMessage('');
|
||||
setStageAll(true);
|
||||
setCommitDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCommit = useCallback(async () => {
|
||||
if (!commitMessage.trim()) return;
|
||||
setCommitDialogOpen(false);
|
||||
await handleGitOperation(
|
||||
() => commit(commitMessage.trim(), stageAll),
|
||||
'Commit'
|
||||
);
|
||||
setCommitMessage('');
|
||||
}, [commitMessage, stageAll, handleGitOperation, commit]);
|
||||
|
||||
const handleOpenPRDialog = useCallback(() => {
|
||||
setPrTitle(taskTitle || '');
|
||||
setPrBody('');
|
||||
setPrDialogOpen(true);
|
||||
}, [taskTitle]);
|
||||
|
||||
const handleCreatePR = useCallback(async () => {
|
||||
if (!prTitle.trim()) return;
|
||||
setPrDialogOpen(false);
|
||||
try {
|
||||
const result = await createPR(
|
||||
prTitle.trim(),
|
||||
prBody.trim(),
|
||||
baseBranch,
|
||||
prDraft
|
||||
);
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: prDraft ? 'Draft PR created' : 'PR created',
|
||||
description: result.pr_url || 'PR created successfully',
|
||||
variant: 'success',
|
||||
});
|
||||
if (result.pr_url) window.open(result.pr_url, '_blank');
|
||||
} else {
|
||||
toast({
|
||||
title: 'Create PR failed',
|
||||
description: result.error || 'An error occurred',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Create PR failed',
|
||||
description:
|
||||
e instanceof Error ? e.message : 'An error occurred',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
setPrTitle('');
|
||||
setPrBody('');
|
||||
}, [prTitle, prBody, baseBranch, prDraft, createPR, toast]);
|
||||
|
||||
// --- dynamic primary action ---
|
||||
|
||||
const primaryAction =
|
||||
uncommittedFileCount > 0
|
||||
? ('commit' as const)
|
||||
: aheadCount > 0
|
||||
? ('pr' as const)
|
||||
: behindCount > 0
|
||||
? ('rebase' as const)
|
||||
: ('commit' as const);
|
||||
|
||||
const primaryButtonConfig = {
|
||||
commit: {
|
||||
icon: <IconGitCommit className="h-4 w-4" />,
|
||||
label: 'Commit',
|
||||
badge: uncommittedFileCount > 0 ? uncommittedFileCount : null,
|
||||
tooltip:
|
||||
uncommittedFileCount > 0
|
||||
? `Commit ${uncommittedFileCount} changed file${uncommittedFileCount !== 1 ? 's' : ''}`
|
||||
: 'No changes to commit',
|
||||
onClick: handleOpenCommitDialog,
|
||||
},
|
||||
pr: {
|
||||
icon: <IconGitPullRequest className="h-4 w-4" />,
|
||||
label: 'Create PR',
|
||||
badge: aheadCount > 0 ? aheadCount : null,
|
||||
tooltip: `Create PR (${aheadCount} commit${aheadCount !== 1 ? 's' : ''} ahead)`,
|
||||
onClick: handleOpenPRDialog,
|
||||
},
|
||||
rebase: {
|
||||
icon: <IconGitCherryPick className="h-4 w-4" />,
|
||||
label: 'Rebase',
|
||||
badge: behindCount > 0 ? behindCount : null,
|
||||
tooltip: `Rebase onto ${baseBranch || 'origin/main'} (${behindCount} behind)`,
|
||||
onClick: handleRebase,
|
||||
},
|
||||
}[primaryAction];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Split button */}
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-none border-0 cursor-pointer"
|
||||
onClick={primaryButtonConfig.onClick}
|
||||
disabled={isGitLoading || !sessionId}
|
||||
>
|
||||
{primaryButtonConfig.icon}
|
||||
{primaryButtonConfig.label}
|
||||
{primaryButtonConfig.badge != null && (
|
||||
<span className="ml-1 rounded-full bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{primaryButtonConfig.badge}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{primaryButtonConfig.tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-none border-0 border-l px-2 cursor-pointer"
|
||||
disabled={isGitLoading || !sessionId}
|
||||
>
|
||||
{isGitLoading ? (
|
||||
<IconLoader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<IconChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-3"
|
||||
onClick={handleOpenPRDialog}
|
||||
disabled={isGitLoading || !sessionId}
|
||||
>
|
||||
<IconGitPullRequest className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex-1">Create PR</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-3"
|
||||
onClick={handlePull}
|
||||
disabled={isGitLoading || !sessionId}
|
||||
>
|
||||
<IconCloudDownload className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex-1">Pull</span>
|
||||
{hasMatchingUpstream && (gitStatus?.behind ?? 0) > 0 && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
↓{gitStatus?.behind}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
className="cursor-pointer gap-3"
|
||||
disabled={isGitLoading || !sessionId}
|
||||
>
|
||||
<IconCloudUpload className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex-1">Push</span>
|
||||
{hasMatchingUpstream && (gitStatus?.ahead ?? 0) > 0 && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
↑{gitStatus?.ahead}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-3"
|
||||
onClick={() => handlePush(false)}
|
||||
disabled={isGitLoading || !sessionId}
|
||||
>
|
||||
<IconCloudUpload className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Push</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-3"
|
||||
onClick={() => handlePush(true)}
|
||||
disabled={isGitLoading || !sessionId}
|
||||
>
|
||||
<IconAlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Force Push</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-3"
|
||||
onClick={handleRebase}
|
||||
disabled={isGitLoading || !sessionId}
|
||||
>
|
||||
<IconGitCherryPick className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex-1">Rebase</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
onto {baseBranch || 'origin/main'}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-3"
|
||||
onClick={handleMerge}
|
||||
disabled={isGitLoading || !sessionId}
|
||||
>
|
||||
<IconGitMerge className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex-1">Merge</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
from {baseBranch || 'origin/main'}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Commit Dialog */}
|
||||
<Dialog open={commitDialogOpen} onOpenChange={setCommitDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<IconGitCommit className="h-5 w-5" />
|
||||
Commit Changes
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{uncommittedFileCount > 0 ? (
|
||||
<span>
|
||||
<span className="font-medium text-foreground">
|
||||
{uncommittedFileCount}
|
||||
</span>{' '}
|
||||
file{uncommittedFileCount !== 1 ? 's' : ''} changed
|
||||
{(uncommittedAdditions > 0 || uncommittedDeletions > 0) && (
|
||||
<span className="ml-2">
|
||||
(
|
||||
<span className="text-green-600">
|
||||
+{uncommittedAdditions}
|
||||
</span>
|
||||
{' / '}
|
||||
<span className="text-red-600">
|
||||
-{uncommittedDeletions}
|
||||
</span>
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>No changes to commit</span>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="Enter commit message..."
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="vcs-stage-all"
|
||||
checked={stageAll}
|
||||
onCheckedChange={(checked) => setStageAll(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="vcs-stage-all"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
Stage all changes before committing
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={handleCommit}
|
||||
disabled={!commitMessage.trim() || isGitLoading}
|
||||
>
|
||||
{isGitLoading ? (
|
||||
<>
|
||||
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Committing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconCheck className="h-4 w-4 mr-2" />
|
||||
Commit
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Create PR Dialog */}
|
||||
<Dialog open={prDialogOpen} onOpenChange={setPrDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<IconGitPullRequest className="h-5 w-5" />
|
||||
Create Pull Request
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
{displayBranch && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{baseBranch ? (
|
||||
<span>
|
||||
Creating PR from{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{displayBranch}
|
||||
</span>{' '}
|
||||
→{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{baseBranch}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Creating PR from{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{displayBranch}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vcs-pr-title" className="text-sm">
|
||||
Title
|
||||
</Label>
|
||||
<input
|
||||
id="vcs-pr-title"
|
||||
type="text"
|
||||
placeholder="Pull request title..."
|
||||
value={prTitle}
|
||||
onChange={(e) => setPrTitle(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vcs-pr-body" className="text-sm">
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
id="vcs-pr-body"
|
||||
placeholder="Describe your changes..."
|
||||
value={prBody}
|
||||
onChange={(e) => setPrBody(e.target.value)}
|
||||
rows={6}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="vcs-pr-draft"
|
||||
checked={prDraft}
|
||||
onCheckedChange={(checked) => setPrDraft(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="vcs-pr-draft"
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
Create as draft
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={handleCreatePR}
|
||||
disabled={!prTitle.trim() || isGitLoading}
|
||||
>
|
||||
{isGitLoading ? (
|
||||
<>
|
||||
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconGitPullRequest className="h-4 w-4 mr-2" />
|
||||
Create PR
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export { VcsSplitButton };
|
||||
Reference in New Issue
Block a user