branch filtering

This commit is contained in:
James
2025-12-22 18:33:49 -05:00
parent ffcdbf7d75
commit 12a796bcbb
11 changed files with 591 additions and 44 deletions

View File

@@ -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';

View File

@@ -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]);
}

View File

@@ -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 };
}