- Added process registration and health tracking capabilities in TeamDataService, allowing teams to monitor and manage background processes effectively. - Introduced new CLI commands for registering, unregistering, and listing processes, enhancing the command-line tool's functionality. - Implemented periodic health checks for registered processes, automatically updating their status and notifying the UI of changes. - Enhanced the FileWatcher to emit team change events for process updates, ensuring real-time synchronization with the UI. - Updated the team detail view to display active CLI processes, improving visibility into team operations. - Added documentation for new process management protocols and CLI commands to assist users in managing background processes.
270 lines
7.9 KiB
TypeScript
270 lines
7.9 KiB
TypeScript
import { useEffect, useMemo } from 'react';
|
|
|
|
import { cn } from '@renderer/lib/utils';
|
|
import { useStore } from '@renderer/store';
|
|
import { Check, Circle, CircleDot, File, FolderOpen, X as XIcon } from 'lucide-react';
|
|
|
|
import type { HunkDecision } from '@shared/types';
|
|
import type { FileChangeSummary } from '@shared/types/review';
|
|
|
|
interface ReviewFileTreeProps {
|
|
files: FileChangeSummary[];
|
|
selectedFilePath: string | null;
|
|
onSelectFile: (filePath: string) => void;
|
|
viewedSet?: Set<string>;
|
|
onMarkViewed?: (filePath: string) => void;
|
|
onUnmarkViewed?: (filePath: string) => void;
|
|
activeFilePath?: string;
|
|
}
|
|
|
|
interface TreeNode {
|
|
name: string;
|
|
fullPath: string;
|
|
isFile: boolean;
|
|
file?: FileChangeSummary;
|
|
children: TreeNode[];
|
|
}
|
|
|
|
type FileStatus = 'pending' | 'accepted' | 'rejected' | 'mixed';
|
|
|
|
function buildTree(files: FileChangeSummary[]): TreeNode[] {
|
|
const root: TreeNode = { name: '', fullPath: '', isFile: false, children: [] };
|
|
|
|
for (const file of files) {
|
|
const parts = file.relativePath.split('/');
|
|
let current = root;
|
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const part = parts[i];
|
|
const isLast = i === parts.length - 1;
|
|
const fullPath = parts.slice(0, i + 1).join('/');
|
|
|
|
let child = current.children.find((c) => c.name === part);
|
|
if (!child) {
|
|
child = {
|
|
name: part,
|
|
fullPath,
|
|
isFile: isLast,
|
|
file: isLast ? file : undefined,
|
|
children: [],
|
|
};
|
|
current.children.push(child);
|
|
}
|
|
current = child;
|
|
}
|
|
}
|
|
|
|
function collapse(node: TreeNode): TreeNode {
|
|
const collapsed: TreeNode = { ...node, children: node.children.map(collapse) };
|
|
if (!collapsed.isFile && collapsed.children.length === 1 && !collapsed.children[0].isFile) {
|
|
const child = collapsed.children[0];
|
|
return {
|
|
...child,
|
|
name: `${collapsed.name}/${child.name}`,
|
|
children: child.children,
|
|
};
|
|
}
|
|
return collapsed;
|
|
}
|
|
|
|
return collapse(root).children;
|
|
}
|
|
|
|
function getFileStatus(
|
|
file: FileChangeSummary,
|
|
hunkDecisions: Record<string, HunkDecision>
|
|
): FileStatus {
|
|
if (file.snippets.length === 0) return 'pending';
|
|
|
|
const decisions: HunkDecision[] = [];
|
|
for (let i = 0; i < file.snippets.length; i++) {
|
|
const key = `${file.filePath}:${i}`;
|
|
decisions.push(hunkDecisions[key] ?? 'pending');
|
|
}
|
|
|
|
const allAccepted = decisions.every((d) => d === 'accepted');
|
|
const allRejected = decisions.every((d) => d === 'rejected');
|
|
const allPending = decisions.every((d) => d === 'pending');
|
|
|
|
if (allPending) return 'pending';
|
|
if (allAccepted) return 'accepted';
|
|
if (allRejected) return 'rejected';
|
|
return 'mixed';
|
|
}
|
|
|
|
const FileStatusIcon = ({ status }: { status: FileStatus }) => {
|
|
switch (status) {
|
|
case 'accepted':
|
|
return <Check className="size-3 shrink-0 text-green-400" />;
|
|
case 'rejected':
|
|
return <XIcon className="size-3 shrink-0 text-red-400" />;
|
|
case 'mixed':
|
|
return <CircleDot className="size-3 shrink-0 text-yellow-400" />;
|
|
case 'pending':
|
|
default:
|
|
return <Circle className="size-3 shrink-0 text-zinc-500" />;
|
|
}
|
|
};
|
|
|
|
const TreeItem = ({
|
|
node,
|
|
selectedFilePath,
|
|
activeFilePath,
|
|
onSelectFile,
|
|
depth,
|
|
hunkDecisions,
|
|
viewedSet,
|
|
onMarkViewed,
|
|
onUnmarkViewed,
|
|
}: {
|
|
node: TreeNode;
|
|
selectedFilePath: string | null;
|
|
activeFilePath?: string;
|
|
onSelectFile: (filePath: string) => void;
|
|
depth: number;
|
|
hunkDecisions: Record<string, HunkDecision>;
|
|
viewedSet?: Set<string>;
|
|
onMarkViewed?: (filePath: string) => void;
|
|
onUnmarkViewed?: (filePath: string) => void;
|
|
}) => {
|
|
if (node.isFile && node.file) {
|
|
const isSelected = node.file.filePath === selectedFilePath;
|
|
const isActive = node.file.filePath === activeFilePath && !isSelected;
|
|
const status = getFileStatus(node.file, hunkDecisions);
|
|
return (
|
|
<button
|
|
data-tree-file={node.file.filePath}
|
|
onClick={() => onSelectFile(node.file!.filePath)}
|
|
className={cn(
|
|
'flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors',
|
|
isSelected
|
|
? 'bg-blue-500/20 text-blue-300'
|
|
: isActive
|
|
? 'border-l-2 border-blue-400 text-text'
|
|
: 'text-text-secondary hover:bg-surface-raised hover:text-text'
|
|
)}
|
|
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
|
>
|
|
<FileStatusIcon status={status} />
|
|
<File className="size-3.5 shrink-0" />
|
|
{viewedSet && (
|
|
<input
|
|
type="checkbox"
|
|
checked={viewedSet.has(node.file.filePath)}
|
|
onChange={(e) => {
|
|
e.stopPropagation();
|
|
if (e.target.checked) {
|
|
onMarkViewed?.(node.file!.filePath);
|
|
} else {
|
|
onUnmarkViewed?.(node.file!.filePath);
|
|
}
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="size-3 shrink-0 rounded border-zinc-600 accent-green-500"
|
|
aria-label={`Mark ${node.name} as viewed`}
|
|
/>
|
|
)}
|
|
<span
|
|
className={cn(
|
|
'min-w-0 flex-1 truncate',
|
|
status === 'rejected' && 'text-text-muted line-through'
|
|
)}
|
|
>
|
|
{node.name}
|
|
</span>
|
|
<span className="ml-1 flex shrink-0 items-center gap-1">
|
|
{node.file.linesAdded > 0 && (
|
|
<span className="text-green-400">+{node.file.linesAdded}</span>
|
|
)}
|
|
{node.file.linesRemoved > 0 && (
|
|
<span className="text-red-400">-{node.file.linesRemoved}</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div
|
|
className="flex items-center gap-2 px-2 py-1 text-xs text-text-muted"
|
|
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
|
>
|
|
<FolderOpen className="size-3.5 shrink-0" />
|
|
<span className="truncate">{node.name}</span>
|
|
</div>
|
|
{[...node.children]
|
|
.sort((a, b) => {
|
|
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
|
|
return a.name.localeCompare(b.name);
|
|
})
|
|
.map((child) => (
|
|
<TreeItem
|
|
key={child.fullPath}
|
|
node={child}
|
|
selectedFilePath={selectedFilePath}
|
|
activeFilePath={activeFilePath}
|
|
onSelectFile={onSelectFile}
|
|
depth={depth + 1}
|
|
hunkDecisions={hunkDecisions}
|
|
viewedSet={viewedSet}
|
|
onMarkViewed={onMarkViewed}
|
|
onUnmarkViewed={onUnmarkViewed}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const ReviewFileTree = ({
|
|
files,
|
|
selectedFilePath,
|
|
onSelectFile,
|
|
viewedSet,
|
|
onMarkViewed,
|
|
onUnmarkViewed,
|
|
activeFilePath,
|
|
}: ReviewFileTreeProps) => {
|
|
const hunkDecisions = useStore((state) => state.hunkDecisions);
|
|
const tree = useMemo(() => buildTree(files), [files]);
|
|
|
|
// Auto-scroll tree to active file when scroll-spy updates
|
|
useEffect(() => {
|
|
if (!activeFilePath) return;
|
|
|
|
const btn = document.querySelector<HTMLElement>(
|
|
`[data-tree-file="${CSS.escape(activeFilePath)}"]`
|
|
);
|
|
if (btn) {
|
|
btn.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
}
|
|
}, [activeFilePath]);
|
|
|
|
if (files.length === 0) {
|
|
return <div className="p-4 text-center text-xs text-text-muted">No changed files</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="py-1">
|
|
{[...tree]
|
|
.sort((a, b) => {
|
|
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
|
|
return a.name.localeCompare(b.name);
|
|
})
|
|
.map((node) => (
|
|
<TreeItem
|
|
key={node.fullPath}
|
|
node={node}
|
|
selectedFilePath={selectedFilePath}
|
|
activeFilePath={activeFilePath}
|
|
onSelectFile={onSelectFile}
|
|
depth={0}
|
|
hunkDecisions={hunkDecisions}
|
|
viewedSet={viewedSet}
|
|
onMarkViewed={onMarkViewed}
|
|
onUnmarkViewed={onUnmarkViewed}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|