mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
branch filtering
This commit is contained in:
@@ -1036,6 +1036,8 @@ export function BoardView() {
|
||||
currentWorktreePath={currentWorktreePath}
|
||||
currentWorktreeBranch={currentWorktreeBranch}
|
||||
projectPath={currentProject?.path || null}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
onEditFeature={(feature) => setEditingFeature(feature)}
|
||||
onViewOutput={handleViewOutput}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Feature } from '@/store/app-store';
|
||||
export interface DependencyEdgeData {
|
||||
sourceStatus: Feature['status'];
|
||||
targetStatus: Feature['status'];
|
||||
isHighlighted?: boolean;
|
||||
isDimmed?: boolean;
|
||||
}
|
||||
|
||||
const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => {
|
||||
@@ -52,11 +54,17 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
||||
curvature: 0.25,
|
||||
});
|
||||
|
||||
const edgeColor = edgeData
|
||||
? getEdgeColor(edgeData.sourceStatus, edgeData.targetStatus)
|
||||
: 'var(--border)';
|
||||
const isHighlighted = edgeData?.isHighlighted ?? false;
|
||||
const isDimmed = edgeData?.isDimmed ?? false;
|
||||
|
||||
const isCompleted = edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified';
|
||||
const edgeColor = isHighlighted
|
||||
? 'var(--brand-500)'
|
||||
: edgeData
|
||||
? getEdgeColor(edgeData.sourceStatus, edgeData.targetStatus)
|
||||
: 'var(--border)';
|
||||
|
||||
const isCompleted =
|
||||
edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified';
|
||||
const isInProgress = edgeData?.targetStatus === 'in_progress';
|
||||
|
||||
return (
|
||||
@@ -66,8 +74,9 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
||||
id={`${id}-bg`}
|
||||
path={edgePath}
|
||||
style={{
|
||||
strokeWidth: 4,
|
||||
strokeWidth: isHighlighted ? 6 : 4,
|
||||
stroke: 'var(--background)',
|
||||
opacity: isDimmed ? 0.3 : 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -78,13 +87,20 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
||||
className={cn(
|
||||
'transition-all duration-300',
|
||||
animated && 'animated-edge',
|
||||
isInProgress && 'edge-flowing'
|
||||
isInProgress && 'edge-flowing',
|
||||
isHighlighted && 'graph-edge-highlighted',
|
||||
isDimmed && 'graph-edge-dimmed'
|
||||
)}
|
||||
style={{
|
||||
strokeWidth: selected ? 3 : 2,
|
||||
strokeWidth: isHighlighted ? 4 : selected ? 3 : isDimmed ? 1 : 2,
|
||||
stroke: edgeColor,
|
||||
strokeDasharray: isCompleted ? 'none' : '5 5',
|
||||
filter: selected ? 'drop-shadow(0 0 3px var(--brand-500))' : 'none',
|
||||
filter: isHighlighted
|
||||
? 'drop-shadow(0 0 6px var(--brand-500))'
|
||||
: selected
|
||||
? 'drop-shadow(0 0 3px var(--brand-500))'
|
||||
: 'none',
|
||||
opacity: isDimmed ? 0.2 : 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Panel } from '@xyflow/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Filter, X, Eye, EyeOff, ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { GraphFilterState } from '../hooks/use-graph-filter';
|
||||
|
||||
interface GraphFilterControlsProps {
|
||||
filterState: GraphFilterState;
|
||||
availableCategories: string[];
|
||||
hasActiveFilter: boolean;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
onCategoriesChange: (categories: string[]) => void;
|
||||
onNegativeFilterChange: (isNegative: boolean) => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
export function GraphFilterControls({
|
||||
filterState,
|
||||
availableCategories,
|
||||
hasActiveFilter,
|
||||
onSearchQueryChange,
|
||||
onCategoriesChange,
|
||||
onNegativeFilterChange,
|
||||
onClearFilters,
|
||||
}: GraphFilterControlsProps) {
|
||||
const { selectedCategories, isNegativeFilter } = filterState;
|
||||
|
||||
const handleCategoryToggle = (category: string) => {
|
||||
if (selectedCategories.includes(category)) {
|
||||
onCategoriesChange(selectedCategories.filter((c) => c !== category));
|
||||
} else {
|
||||
onCategoriesChange([...selectedCategories, category]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAllCategories = () => {
|
||||
if (selectedCategories.length === availableCategories.length) {
|
||||
onCategoriesChange([]);
|
||||
} else {
|
||||
onCategoriesChange([...availableCategories]);
|
||||
}
|
||||
};
|
||||
|
||||
const categoryButtonLabel =
|
||||
selectedCategories.length === 0
|
||||
? 'All Categories'
|
||||
: selectedCategories.length === 1
|
||||
? selectedCategories[0]
|
||||
: `${selectedCategories.length} Categories`;
|
||||
|
||||
return (
|
||||
<Panel position="top-left" className="flex items-center gap-2">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
|
||||
{/* Category Filter Dropdown */}
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-8 px-2 gap-1.5',
|
||||
selectedCategories.length > 0 && 'bg-brand-500/20 text-brand-500'
|
||||
)}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
<span className="text-xs max-w-[100px] truncate">{categoryButtonLabel}</span>
|
||||
<ChevronDown className="w-3 h-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Filter by Category</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent align="start" className="w-56 p-2">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground px-2 py-1">
|
||||
Categories
|
||||
</div>
|
||||
|
||||
{/* Select All option */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
|
||||
onClick={handleSelectAllCategories}
|
||||
>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedCategories.length === availableCategories.length &&
|
||||
availableCategories.length > 0
|
||||
}
|
||||
onCheckedChange={handleSelectAllCategories}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{selectedCategories.length === availableCategories.length
|
||||
? 'Deselect All'
|
||||
: 'Select All'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border" />
|
||||
|
||||
{/* Category list */}
|
||||
<div className="max-h-48 overflow-y-auto space-y-0.5">
|
||||
{availableCategories.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground px-2 py-2">
|
||||
No categories available
|
||||
</div>
|
||||
) : (
|
||||
availableCategories.map((category) => (
|
||||
<div
|
||||
key={category}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
|
||||
onClick={() => handleCategoryToggle(category)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedCategories.includes(category)}
|
||||
onCheckedChange={() => handleCategoryToggle(category)}
|
||||
/>
|
||||
<span className="text-sm truncate">{category}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-6 w-px bg-border" />
|
||||
|
||||
{/* Positive/Negative Filter Toggle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onNegativeFilterChange(!isNegativeFilter)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors',
|
||||
isNegativeFilter
|
||||
? 'bg-orange-500/20 text-orange-500'
|
||||
: 'hover:bg-accent text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{isNegativeFilter ? (
|
||||
<>
|
||||
<EyeOff className="w-3.5 h-3.5" />
|
||||
<span>Hide</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>Show</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Switch
|
||||
checked={isNegativeFilter}
|
||||
onCheckedChange={onNegativeFilterChange}
|
||||
className="h-5 w-9 data-[state=checked]:bg-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isNegativeFilter
|
||||
? 'Negative filter: Highlighting non-matching nodes'
|
||||
: 'Positive filter: Highlighting matching nodes'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Clear Filters Button - only show when filters are active */}
|
||||
{hasActiveFilter && (
|
||||
<>
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Clear All Filters</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export { TaskNode } from './task-node';
|
||||
export { DependencyEdge } from './dependency-edge';
|
||||
export { GraphControls } from './graph-controls';
|
||||
export { GraphLegend } from './graph-legend';
|
||||
export { GraphFilterControls } from './graph-filter-controls';
|
||||
|
||||
@@ -72,14 +72,16 @@ const priorityConfig = {
|
||||
3: { label: 'Low', colorClass: 'bg-[var(--status-info)] text-white' },
|
||||
};
|
||||
|
||||
export const TaskNode = memo(function TaskNode({
|
||||
data,
|
||||
selected,
|
||||
}: TaskNodeProps) {
|
||||
export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) {
|
||||
const config = statusConfig[data.status] || statusConfig.backlog;
|
||||
const StatusIcon = config.icon;
|
||||
const priorityConf = data.priority ? priorityConfig[data.priority as 1 | 2 | 3] : null;
|
||||
|
||||
// Filter highlight states
|
||||
const isMatched = data.isMatched ?? false;
|
||||
const isHighlighted = data.isHighlighted ?? false;
|
||||
const isDimmed = data.isDimmed ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Target handle (left side - receives dependencies) */}
|
||||
@@ -89,39 +91,46 @@ export const TaskNode = memo(function TaskNode({
|
||||
className={cn(
|
||||
'w-3 h-3 !bg-border border-2 border-background',
|
||||
'transition-colors duration-200',
|
||||
'hover:!bg-brand-500'
|
||||
'hover:!bg-brand-500',
|
||||
isDimmed && 'opacity-30'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-[240px] max-w-[280px] rounded-xl border-2 bg-card shadow-md',
|
||||
'transition-all duration-200',
|
||||
'transition-all duration-300',
|
||||
config.borderClass,
|
||||
selected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background',
|
||||
data.isRunning && 'animate-pulse-subtle',
|
||||
data.error && 'border-[var(--status-error)]'
|
||||
data.error && 'border-[var(--status-error)]',
|
||||
// Filter highlight states
|
||||
isMatched && 'graph-node-matched',
|
||||
isHighlighted && !isMatched && 'graph-node-highlighted',
|
||||
isDimmed && 'graph-node-dimmed'
|
||||
)}
|
||||
>
|
||||
{/* Header with status and actions */}
|
||||
<div className={cn(
|
||||
'flex items-center justify-between px-3 py-2 rounded-t-[10px]',
|
||||
config.bgClass
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between px-3 py-2 rounded-t-[10px]',
|
||||
config.bgClass
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon className={cn('w-4 h-4', config.colorClass)} />
|
||||
<span className={cn('text-xs font-medium', config.colorClass)}>
|
||||
{config.label}
|
||||
</span>
|
||||
<span className={cn('text-xs font-medium', config.colorClass)}>{config.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Priority badge */}
|
||||
{priorityConf && (
|
||||
<span className={cn(
|
||||
'text-[10px] font-bold px-1.5 py-0.5 rounded',
|
||||
priorityConf.colorClass
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-bold px-1.5 py-0.5 rounded',
|
||||
priorityConf.colorClass
|
||||
)}
|
||||
>
|
||||
{data.priority === 1 ? 'H' : data.priority === 2 ? 'M' : 'L'}
|
||||
</span>
|
||||
)}
|
||||
@@ -161,11 +170,7 @@ export const TaskNode = memo(function TaskNode({
|
||||
{/* Actions dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-background/50"
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 hover:bg-background/50">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -212,9 +217,7 @@ export const TaskNode = memo(function TaskNode({
|
||||
{data.isRunning && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--status-in-progress)] rounded-full animate-progress-indeterminate"
|
||||
/>
|
||||
<div className="h-full bg-[var(--status-in-progress)] rounded-full animate-progress-indeterminate" />
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">Running...</span>
|
||||
</div>
|
||||
@@ -240,7 +243,8 @@ export const TaskNode = memo(function TaskNode({
|
||||
'hover:!bg-brand-500',
|
||||
data.status === 'completed' || data.status === 'verified'
|
||||
? '!bg-[var(--status-success)]'
|
||||
: ''
|
||||
: '',
|
||||
isDimmed && 'opacity-30'
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -14,8 +14,20 @@ import {
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { TaskNode, DependencyEdge, GraphControls, GraphLegend } from './components';
|
||||
import { useGraphNodes, useGraphLayout, type TaskNodeData } from './hooks';
|
||||
import {
|
||||
TaskNode,
|
||||
DependencyEdge,
|
||||
GraphControls,
|
||||
GraphLegend,
|
||||
GraphFilterControls,
|
||||
} from './components';
|
||||
import {
|
||||
useGraphNodes,
|
||||
useGraphLayout,
|
||||
useGraphFilter,
|
||||
type TaskNodeData,
|
||||
type GraphFilterState,
|
||||
} from './hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Define custom node and edge types - using any to avoid React Flow's strict typing
|
||||
@@ -32,6 +44,8 @@ const edgeTypes: any = {
|
||||
interface GraphCanvasProps {
|
||||
features: Feature[];
|
||||
runningAutoTasks: string[];
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
onNodeClick?: (featureId: string) => void;
|
||||
onNodeDoubleClick?: (featureId: string) => void;
|
||||
backgroundStyle?: React.CSSProperties;
|
||||
@@ -41,6 +55,8 @@ interface GraphCanvasProps {
|
||||
function GraphCanvasInner({
|
||||
features,
|
||||
runningAutoTasks,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
onNodeClick,
|
||||
onNodeDoubleClick,
|
||||
backgroundStyle,
|
||||
@@ -49,10 +65,25 @@ function GraphCanvasInner({
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR');
|
||||
|
||||
// Transform features to nodes and edges
|
||||
// Filter state (category and negative toggle are local to graph view)
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [isNegativeFilter, setIsNegativeFilter] = useState(false);
|
||||
|
||||
// Combined filter state
|
||||
const filterState: GraphFilterState = {
|
||||
searchQuery,
|
||||
selectedCategories,
|
||||
isNegativeFilter,
|
||||
};
|
||||
|
||||
// Calculate filter results
|
||||
const filterResult = useGraphFilter(features, filterState);
|
||||
|
||||
// Transform features to nodes and edges with filter results
|
||||
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
|
||||
features,
|
||||
runningAutoTasks,
|
||||
filterResult,
|
||||
});
|
||||
|
||||
// Apply layout
|
||||
@@ -80,6 +111,13 @@ function GraphCanvasInner({
|
||||
[runLayout]
|
||||
);
|
||||
|
||||
// Handle clear all filters
|
||||
const handleClearFilters = useCallback(() => {
|
||||
onSearchQueryChange('');
|
||||
setSelectedCategories([]);
|
||||
setIsNegativeFilter(false);
|
||||
}, [onSearchQueryChange]);
|
||||
|
||||
// Handle node click
|
||||
const handleNodeClick = useCallback(
|
||||
(_event: React.MouseEvent, node: Node<TaskNodeData>) => {
|
||||
@@ -158,6 +196,16 @@ function GraphCanvasInner({
|
||||
layoutDirection={layoutDirection}
|
||||
/>
|
||||
|
||||
<GraphFilterControls
|
||||
filterState={filterState}
|
||||
availableCategories={filterResult.availableCategories}
|
||||
hasActiveFilter={filterResult.hasActiveFilter}
|
||||
onSearchQueryChange={onSearchQueryChange}
|
||||
onCategoriesChange={setSelectedCategories}
|
||||
onNegativeFilterChange={setIsNegativeFilter}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
|
||||
<GraphLegend />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@ interface GraphViewProps {
|
||||
currentWorktreePath: string | null;
|
||||
currentWorktreeBranch: string | null;
|
||||
projectPath: string | null;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
onEditFeature: (feature: Feature) => void;
|
||||
onViewOutput: (feature: Feature) => void;
|
||||
}
|
||||
@@ -19,6 +21,8 @@ export function GraphView({
|
||||
currentWorktreePath,
|
||||
currentWorktreeBranch,
|
||||
projectPath,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
onEditFeature,
|
||||
onViewOutput,
|
||||
}: GraphViewProps) {
|
||||
@@ -79,6 +83,8 @@ export function GraphView({
|
||||
<GraphCanvas
|
||||
features={filteredFeatures}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={onSearchQueryChange}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
backgroundStyle={backgroundImageStyle}
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
export { useGraphNodes, type TaskNode, type DependencyEdge, type TaskNodeData } from './use-graph-nodes';
|
||||
export {
|
||||
useGraphNodes,
|
||||
type TaskNode,
|
||||
type DependencyEdge,
|
||||
type TaskNodeData,
|
||||
} from './use-graph-nodes';
|
||||
export { useGraphLayout } from './use-graph-layout';
|
||||
export { useGraphFilter, type GraphFilterState, type GraphFilterResult } from './use-graph-filter';
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Feature } from '@/store/app-store';
|
||||
|
||||
export interface GraphFilterState {
|
||||
searchQuery: string;
|
||||
selectedCategories: string[];
|
||||
isNegativeFilter: boolean;
|
||||
}
|
||||
|
||||
export interface GraphFilterResult {
|
||||
matchedNodeIds: Set<string>;
|
||||
highlightedNodeIds: Set<string>;
|
||||
highlightedEdgeIds: Set<string>;
|
||||
availableCategories: string[];
|
||||
hasActiveFilter: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses up the dependency tree to find all ancestors of a node
|
||||
*/
|
||||
function getAncestors(
|
||||
featureId: string,
|
||||
featureMap: Map<string, Feature>,
|
||||
visited: Set<string>
|
||||
): void {
|
||||
if (visited.has(featureId)) return;
|
||||
visited.add(featureId);
|
||||
|
||||
const feature = featureMap.get(featureId);
|
||||
if (!feature?.dependencies) return;
|
||||
|
||||
for (const depId of feature.dependencies) {
|
||||
if (featureMap.has(depId)) {
|
||||
getAncestors(depId, featureMap, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses down to find all descendants (features that depend on this one)
|
||||
*/
|
||||
function getDescendants(featureId: string, features: Feature[], visited: Set<string>): void {
|
||||
if (visited.has(featureId)) return;
|
||||
visited.add(featureId);
|
||||
|
||||
for (const feature of features) {
|
||||
if (feature.dependencies?.includes(featureId)) {
|
||||
getDescendants(feature.id, features, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all edges in the highlighted path
|
||||
*/
|
||||
function getHighlightedEdges(highlightedNodeIds: Set<string>, features: Feature[]): Set<string> {
|
||||
const edges = new Set<string>();
|
||||
|
||||
for (const feature of features) {
|
||||
if (!highlightedNodeIds.has(feature.id)) continue;
|
||||
if (!feature.dependencies) continue;
|
||||
|
||||
for (const depId of feature.dependencies) {
|
||||
if (highlightedNodeIds.has(depId)) {
|
||||
edges.add(`${depId}->${feature.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to calculate graph filter results based on search query, categories, and filter mode
|
||||
*/
|
||||
export function useGraphFilter(
|
||||
features: Feature[],
|
||||
filterState: GraphFilterState
|
||||
): GraphFilterResult {
|
||||
const { searchQuery, selectedCategories, isNegativeFilter } = filterState;
|
||||
|
||||
return useMemo(() => {
|
||||
// Extract all unique categories
|
||||
const availableCategories = Array.from(
|
||||
new Set(features.map((f) => f.category).filter(Boolean))
|
||||
).sort();
|
||||
|
||||
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||
const hasSearchQuery = normalizedQuery.length > 0;
|
||||
const hasCategoryFilter = selectedCategories.length > 0;
|
||||
const hasActiveFilter = hasSearchQuery || hasCategoryFilter || isNegativeFilter;
|
||||
|
||||
// If no filters active, return empty sets (show all nodes normally)
|
||||
if (!hasActiveFilter) {
|
||||
return {
|
||||
matchedNodeIds: new Set<string>(),
|
||||
highlightedNodeIds: new Set<string>(),
|
||||
highlightedEdgeIds: new Set<string>(),
|
||||
availableCategories,
|
||||
hasActiveFilter: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Find directly matched nodes
|
||||
const matchedNodeIds = new Set<string>();
|
||||
const featureMap = new Map(features.map((f) => [f.id, f]));
|
||||
|
||||
for (const feature of features) {
|
||||
let matchesSearch = true;
|
||||
let matchesCategory = true;
|
||||
|
||||
// Check search query match (title or description)
|
||||
if (hasSearchQuery) {
|
||||
const titleMatch = feature.title?.toLowerCase().includes(normalizedQuery);
|
||||
const descMatch = feature.description?.toLowerCase().includes(normalizedQuery);
|
||||
matchesSearch = titleMatch || descMatch;
|
||||
}
|
||||
|
||||
// Check category match
|
||||
if (hasCategoryFilter) {
|
||||
matchesCategory = selectedCategories.includes(feature.category);
|
||||
}
|
||||
|
||||
// Both conditions must be true for a match
|
||||
if (matchesSearch && matchesCategory) {
|
||||
matchedNodeIds.add(feature.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply negative filter if enabled (invert the matched set)
|
||||
let effectiveMatchedIds: Set<string>;
|
||||
if (isNegativeFilter) {
|
||||
effectiveMatchedIds = new Set(
|
||||
features.filter((f) => !matchedNodeIds.has(f.id)).map((f) => f.id)
|
||||
);
|
||||
} else {
|
||||
effectiveMatchedIds = matchedNodeIds;
|
||||
}
|
||||
|
||||
// Calculate full path (ancestors + descendants) for highlighted nodes
|
||||
const highlightedNodeIds = new Set<string>();
|
||||
|
||||
for (const id of effectiveMatchedIds) {
|
||||
// Add the matched node itself
|
||||
highlightedNodeIds.add(id);
|
||||
|
||||
// Add all ancestors (dependencies)
|
||||
getAncestors(id, featureMap, highlightedNodeIds);
|
||||
|
||||
// Add all descendants (dependents)
|
||||
getDescendants(id, features, highlightedNodeIds);
|
||||
}
|
||||
|
||||
// Get edges in the highlighted path
|
||||
const highlightedEdgeIds = getHighlightedEdges(highlightedNodeIds, features);
|
||||
|
||||
return {
|
||||
matchedNodeIds: effectiveMatchedIds,
|
||||
highlightedNodeIds,
|
||||
highlightedEdgeIds,
|
||||
availableCategories,
|
||||
hasActiveFilter: true,
|
||||
};
|
||||
}, [features, searchQuery, selectedCategories, isNegativeFilter]);
|
||||
}
|
||||
@@ -2,26 +2,37 @@ import { useMemo } from 'react';
|
||||
import { Node, Edge } from '@xyflow/react';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
import { GraphFilterResult } from './use-graph-filter';
|
||||
|
||||
export interface TaskNodeData extends Feature {
|
||||
isBlocked: boolean;
|
||||
isRunning: boolean;
|
||||
blockingDependencies: string[];
|
||||
// Filter highlight states
|
||||
isMatched?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
isDimmed?: boolean;
|
||||
}
|
||||
|
||||
export type TaskNode = Node<TaskNodeData, 'task'>;
|
||||
export type DependencyEdge = Edge<{ sourceStatus: Feature['status']; targetStatus: Feature['status'] }>;
|
||||
export type DependencyEdge = Edge<{
|
||||
sourceStatus: Feature['status'];
|
||||
targetStatus: Feature['status'];
|
||||
isHighlighted?: boolean;
|
||||
isDimmed?: boolean;
|
||||
}>;
|
||||
|
||||
interface UseGraphNodesProps {
|
||||
features: Feature[];
|
||||
runningAutoTasks: string[];
|
||||
filterResult?: GraphFilterResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms features into React Flow nodes and edges
|
||||
* Creates dependency edges based on feature.dependencies array
|
||||
*/
|
||||
export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps) {
|
||||
export function useGraphNodes({ features, runningAutoTasks, filterResult }: UseGraphNodesProps) {
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
const nodeList: TaskNode[] = [];
|
||||
const edgeList: DependencyEdge[] = [];
|
||||
@@ -30,11 +41,22 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps
|
||||
// Create feature map for quick lookups
|
||||
features.forEach((f) => featureMap.set(f.id, f));
|
||||
|
||||
// Extract filter state
|
||||
const hasActiveFilter = filterResult?.hasActiveFilter ?? false;
|
||||
const matchedNodeIds = filterResult?.matchedNodeIds ?? new Set<string>();
|
||||
const highlightedNodeIds = filterResult?.highlightedNodeIds ?? new Set<string>();
|
||||
const highlightedEdgeIds = filterResult?.highlightedEdgeIds ?? new Set<string>();
|
||||
|
||||
// Create nodes
|
||||
features.forEach((feature) => {
|
||||
const isRunning = runningAutoTasks.includes(feature.id);
|
||||
const blockingDeps = getBlockingDependencies(feature, features);
|
||||
|
||||
// Calculate filter highlight states
|
||||
const isMatched = hasActiveFilter && matchedNodeIds.has(feature.id);
|
||||
const isHighlighted = hasActiveFilter && highlightedNodeIds.has(feature.id);
|
||||
const isDimmed = hasActiveFilter && !highlightedNodeIds.has(feature.id);
|
||||
|
||||
const node: TaskNode = {
|
||||
id: feature.id,
|
||||
type: 'task',
|
||||
@@ -44,6 +66,10 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps
|
||||
isBlocked: blockingDeps.length > 0,
|
||||
isRunning,
|
||||
blockingDependencies: blockingDeps,
|
||||
// Filter states
|
||||
isMatched,
|
||||
isHighlighted,
|
||||
isDimmed,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,8 +81,14 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps
|
||||
// Only create edge if the dependency exists in current view
|
||||
if (featureMap.has(depId)) {
|
||||
const sourceFeature = featureMap.get(depId)!;
|
||||
const edgeId = `${depId}->${feature.id}`;
|
||||
|
||||
// Calculate edge highlight states
|
||||
const edgeIsHighlighted = hasActiveFilter && highlightedEdgeIds.has(edgeId);
|
||||
const edgeIsDimmed = hasActiveFilter && !highlightedEdgeIds.has(edgeId);
|
||||
|
||||
const edge: DependencyEdge = {
|
||||
id: `${depId}->${feature.id}`,
|
||||
id: edgeId,
|
||||
source: depId,
|
||||
target: feature.id,
|
||||
type: 'dependency',
|
||||
@@ -64,6 +96,8 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps
|
||||
data: {
|
||||
sourceStatus: sourceFeature.status,
|
||||
targetStatus: feature.status,
|
||||
isHighlighted: edgeIsHighlighted,
|
||||
isDimmed: edgeIsDimmed,
|
||||
},
|
||||
};
|
||||
edgeList.push(edge);
|
||||
@@ -73,7 +107,7 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps
|
||||
});
|
||||
|
||||
return { nodes: nodeList, edges: edgeList };
|
||||
}, [features, runningAutoTasks]);
|
||||
}, [features, runningAutoTasks, filterResult]);
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
@@ -1049,12 +1049,79 @@
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Graph Filter Highlight States */
|
||||
|
||||
/* Matched node - direct search/filter match */
|
||||
.graph-node-matched {
|
||||
box-shadow:
|
||||
0 0 0 3px var(--brand-500),
|
||||
0 0 20px 4px var(--brand-500);
|
||||
border-color: var(--brand-500) !important;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Animated glow for matched nodes */
|
||||
@keyframes matched-node-glow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 3px var(--brand-500),
|
||||
0 0 15px 2px var(--brand-500);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 3px var(--brand-500),
|
||||
0 0 25px 6px var(--brand-500);
|
||||
}
|
||||
}
|
||||
|
||||
.graph-node-matched {
|
||||
animation: matched-node-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Highlighted path node - part of the dependency path */
|
||||
.graph-node-highlighted {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--brand-400),
|
||||
0 0 12px 2px var(--brand-400);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Dimmed node - not part of filter results */
|
||||
.graph-node-dimmed {
|
||||
opacity: 0.25;
|
||||
filter: grayscale(60%);
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
filter 0.3s ease;
|
||||
}
|
||||
|
||||
.graph-node-dimmed:hover {
|
||||
opacity: 0.4;
|
||||
filter: grayscale(40%);
|
||||
}
|
||||
|
||||
/* Highlighted edge styles */
|
||||
.graph-edge-highlighted path {
|
||||
stroke: var(--brand-500) !important;
|
||||
stroke-width: 4px !important;
|
||||
filter: drop-shadow(0 0 6px var(--brand-500));
|
||||
}
|
||||
|
||||
/* Dimmed edge styles */
|
||||
.graph-edge-dimmed path {
|
||||
opacity: 0.15;
|
||||
stroke-width: 1px !important;
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
/* Reduce motion preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.graph-canvas .animated-edge path,
|
||||
.graph-canvas .edge-flowing path,
|
||||
.animate-pulse-subtle,
|
||||
.animate-progress-indeterminate {
|
||||
.animate-progress-indeterminate,
|
||||
.graph-node-matched {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user