mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge main into refactor/frontend
- Resolved conflicts from apps/app to apps/ui migration - Moved worktree-panel component to apps/ui - Moved dependency-resolver.ts to apps/ui - Removed worktree-selector.tsx (replaced by worktree-panel) - Merged theme updates, file browser improvements, and Gemini fixes - Merged server dependency resolver and auto-mode-service updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
221
apps/server/src/lib/dependency-resolver.ts
Normal file
221
apps/server/src/lib/dependency-resolver.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Dependency Resolution Utility (Server-side)
|
||||
*
|
||||
* Provides topological sorting and dependency analysis for features.
|
||||
* Uses a modified Kahn's algorithm that respects both dependencies and priorities.
|
||||
*/
|
||||
|
||||
import type { Feature } from "../services/feature-loader.js";
|
||||
|
||||
export interface DependencyResolutionResult {
|
||||
orderedFeatures: Feature[]; // Features in dependency-aware order
|
||||
circularDependencies: string[][]; // Groups of IDs forming cycles
|
||||
missingDependencies: Map<string, string[]>; // featureId -> missing dep IDs
|
||||
blockedFeatures: Map<string, string[]>; // featureId -> blocking dep IDs (incomplete dependencies)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves feature dependencies using topological sort with priority-aware ordering.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Build dependency graph and detect missing/blocked dependencies
|
||||
* 2. Apply Kahn's algorithm for topological sort
|
||||
* 3. Within same dependency level, sort by priority (1=high, 2=medium, 3=low)
|
||||
* 4. Detect circular dependencies for features that can't be ordered
|
||||
*
|
||||
* @param features - Array of features to order
|
||||
* @returns Resolution result with ordered features and dependency metadata
|
||||
*/
|
||||
export function resolveDependencies(features: Feature[]): DependencyResolutionResult {
|
||||
const featureMap = new Map<string, Feature>(features.map(f => [f.id, f]));
|
||||
const inDegree = new Map<string, number>();
|
||||
const adjacencyList = new Map<string, string[]>(); // dependencyId -> [dependentIds]
|
||||
const missingDependencies = new Map<string, string[]>();
|
||||
const blockedFeatures = new Map<string, string[]>();
|
||||
|
||||
// Initialize graph structures
|
||||
for (const feature of features) {
|
||||
inDegree.set(feature.id, 0);
|
||||
adjacencyList.set(feature.id, []);
|
||||
}
|
||||
|
||||
// Build dependency graph and detect missing/blocked dependencies
|
||||
for (const feature of features) {
|
||||
const deps = feature.dependencies || [];
|
||||
for (const depId of deps) {
|
||||
if (!featureMap.has(depId)) {
|
||||
// Missing dependency - track it
|
||||
if (!missingDependencies.has(feature.id)) {
|
||||
missingDependencies.set(feature.id, []);
|
||||
}
|
||||
missingDependencies.get(feature.id)!.push(depId);
|
||||
} else {
|
||||
// Valid dependency - add edge to graph
|
||||
adjacencyList.get(depId)!.push(feature.id);
|
||||
inDegree.set(feature.id, (inDegree.get(feature.id) || 0) + 1);
|
||||
|
||||
// Check if dependency is incomplete (blocking)
|
||||
const depFeature = featureMap.get(depId)!;
|
||||
if (depFeature.status !== 'completed' && depFeature.status !== 'verified') {
|
||||
if (!blockedFeatures.has(feature.id)) {
|
||||
blockedFeatures.set(feature.id, []);
|
||||
}
|
||||
blockedFeatures.get(feature.id)!.push(depId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kahn's algorithm with priority-aware selection
|
||||
const queue: Feature[] = [];
|
||||
const orderedFeatures: Feature[] = [];
|
||||
|
||||
// Helper to sort features by priority (lower number = higher priority)
|
||||
const sortByPriority = (a: Feature, b: Feature) =>
|
||||
(a.priority ?? 2) - (b.priority ?? 2);
|
||||
|
||||
// Start with features that have no dependencies (in-degree 0)
|
||||
for (const [id, degree] of inDegree) {
|
||||
if (degree === 0) {
|
||||
queue.push(featureMap.get(id)!);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort initial queue by priority
|
||||
queue.sort(sortByPriority);
|
||||
|
||||
// Process features in topological order
|
||||
while (queue.length > 0) {
|
||||
// Take highest priority feature from queue
|
||||
const current = queue.shift()!;
|
||||
orderedFeatures.push(current);
|
||||
|
||||
// Process features that depend on this one
|
||||
for (const dependentId of adjacencyList.get(current.id) || []) {
|
||||
const currentDegree = inDegree.get(dependentId);
|
||||
if (currentDegree === undefined) {
|
||||
throw new Error(`In-degree not initialized for feature ${dependentId}`);
|
||||
}
|
||||
const newDegree = currentDegree - 1;
|
||||
inDegree.set(dependentId, newDegree);
|
||||
|
||||
if (newDegree === 0) {
|
||||
queue.push(featureMap.get(dependentId)!);
|
||||
// Re-sort queue to maintain priority order
|
||||
queue.sort(sortByPriority);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect circular dependencies (features not in output = part of cycle)
|
||||
const circularDependencies: string[][] = [];
|
||||
const processedIds = new Set(orderedFeatures.map(f => f.id));
|
||||
|
||||
if (orderedFeatures.length < features.length) {
|
||||
// Find cycles using DFS
|
||||
const remaining = features.filter(f => !processedIds.has(f.id));
|
||||
const cycles = detectCycles(remaining, featureMap);
|
||||
circularDependencies.push(...cycles);
|
||||
|
||||
// Add remaining features at end (part of cycles)
|
||||
orderedFeatures.push(...remaining);
|
||||
}
|
||||
|
||||
return {
|
||||
orderedFeatures,
|
||||
circularDependencies,
|
||||
missingDependencies,
|
||||
blockedFeatures
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects circular dependencies using depth-first search
|
||||
*
|
||||
* @param features - Features that couldn't be topologically sorted (potential cycles)
|
||||
* @param featureMap - Map of all features by ID
|
||||
* @returns Array of cycles, where each cycle is an array of feature IDs
|
||||
*/
|
||||
function detectCycles(
|
||||
features: Feature[],
|
||||
featureMap: Map<string, Feature>
|
||||
): string[][] {
|
||||
const cycles: string[][] = [];
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
const currentPath: string[] = [];
|
||||
|
||||
function dfs(featureId: string): boolean {
|
||||
visited.add(featureId);
|
||||
recursionStack.add(featureId);
|
||||
currentPath.push(featureId);
|
||||
|
||||
const feature = featureMap.get(featureId);
|
||||
if (feature) {
|
||||
for (const depId of feature.dependencies || []) {
|
||||
if (!visited.has(depId)) {
|
||||
if (dfs(depId)) return true;
|
||||
} else if (recursionStack.has(depId)) {
|
||||
// Found cycle - extract it
|
||||
const cycleStart = currentPath.indexOf(depId);
|
||||
cycles.push(currentPath.slice(cycleStart));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentPath.pop();
|
||||
recursionStack.delete(featureId);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const feature of features) {
|
||||
if (!visited.has(feature.id)) {
|
||||
dfs(feature.id);
|
||||
}
|
||||
}
|
||||
|
||||
return cycles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a feature's dependencies are satisfied (all complete or verified)
|
||||
*
|
||||
* @param feature - Feature to check
|
||||
* @param allFeatures - All features in the project
|
||||
* @returns true if all dependencies are satisfied, false otherwise
|
||||
*/
|
||||
export function areDependenciesSatisfied(
|
||||
feature: Feature,
|
||||
allFeatures: Feature[]
|
||||
): boolean {
|
||||
if (!feature.dependencies || feature.dependencies.length === 0) {
|
||||
return true; // No dependencies = always ready
|
||||
}
|
||||
|
||||
return feature.dependencies.every((depId: string) => {
|
||||
const dep = allFeatures.find(f => f.id === depId);
|
||||
return dep && (dep.status === 'completed' || dep.status === 'verified');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the blocking dependencies for a feature (dependencies that are incomplete)
|
||||
*
|
||||
* @param feature - Feature to check
|
||||
* @param allFeatures - All features in the project
|
||||
* @returns Array of feature IDs that are blocking this feature
|
||||
*/
|
||||
export function getBlockingDependencies(
|
||||
feature: Feature,
|
||||
allFeatures: Feature[]
|
||||
): string[] {
|
||||
if (!feature.dependencies || feature.dependencies.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return feature.dependencies.filter((depId: string) => {
|
||||
const dep = allFeatures.find(f => f.id === depId);
|
||||
return dep && dep.status !== 'completed' && dep.status !== 'verified';
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import { buildPromptWithImages } from "../lib/prompt-builder.js";
|
||||
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
|
||||
import { createAutoModeOptions } from "../lib/sdk-options.js";
|
||||
import { isAbortError, classifyError } from "../lib/error-handler.js";
|
||||
import { resolveDependencies, areDependenciesSatisfied } from "../lib/dependency-resolver.js";
|
||||
import type { Feature } from "./feature-loader.js";
|
||||
import {
|
||||
getFeatureDir,
|
||||
getFeaturesDir,
|
||||
@@ -29,26 +31,6 @@ import {
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
interface Feature {
|
||||
id: string;
|
||||
category: string;
|
||||
description: string;
|
||||
steps?: string[];
|
||||
status: string;
|
||||
priority?: number;
|
||||
spec?: string;
|
||||
model?: string; // Model to use for this feature
|
||||
imagePaths?: Array<
|
||||
| string
|
||||
| {
|
||||
path: string;
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
interface RunningFeature {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
@@ -1016,8 +998,10 @@ Format your response as a structured markdown document.`;
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
|
||||
const features: Feature[] = [];
|
||||
const allFeatures: Feature[] = [];
|
||||
const pendingFeatures: Feature[] = [];
|
||||
|
||||
// Load all features (for dependency checking)
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const featurePath = path.join(
|
||||
@@ -1028,12 +1012,15 @@ Format your response as a structured markdown document.`;
|
||||
try {
|
||||
const data = await fs.readFile(featurePath, "utf-8");
|
||||
const feature = JSON.parse(data);
|
||||
allFeatures.push(feature);
|
||||
|
||||
// Track pending features separately
|
||||
if (
|
||||
feature.status === "pending" ||
|
||||
feature.status === "ready" ||
|
||||
feature.status === "backlog"
|
||||
) {
|
||||
features.push(feature);
|
||||
pendingFeatures.push(feature);
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid features
|
||||
@@ -1041,8 +1028,15 @@ Format your response as a structured markdown document.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority
|
||||
return features.sort((a, b) => (a.priority || 999) - (b.priority || 999));
|
||||
// Apply dependency-aware ordering
|
||||
const { orderedFeatures } = resolveDependencies(pendingFeatures);
|
||||
|
||||
// Filter to only features with satisfied dependencies
|
||||
const readyFeatures = orderedFeatures.filter(feature =>
|
||||
areDependenciesSatisfied(feature, allFeatures)
|
||||
);
|
||||
|
||||
return readyFeatures;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -1286,17 +1280,13 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
const outputPath = path.join(featureDirForOutput, "agent-output.md");
|
||||
|
||||
// Incremental file writing state
|
||||
let directoryCreated = false;
|
||||
let writeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const WRITE_DEBOUNCE_MS = 500; // Batch writes every 500ms
|
||||
|
||||
// Helper to write current responseText to file
|
||||
const writeToFile = async (): Promise<void> => {
|
||||
try {
|
||||
if (!directoryCreated) {
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
directoryCreated = true;
|
||||
}
|
||||
await fs.writeFile(outputPath, responseText);
|
||||
} catch (error) {
|
||||
// Log but don't crash - file write errors shouldn't stop execution
|
||||
@@ -1310,9 +1300,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
clearTimeout(writeTimeout);
|
||||
}
|
||||
writeTimeout = setTimeout(() => {
|
||||
writeToFile().catch((err) => {
|
||||
console.error(`[AutoMode] Debounced write error:`, err);
|
||||
});
|
||||
writeToFile();
|
||||
}, WRITE_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ export interface Feature {
|
||||
steps?: string[];
|
||||
passes?: boolean;
|
||||
priority?: number;
|
||||
status?: string;
|
||||
dependencies?: string[];
|
||||
spec?: string;
|
||||
model?: string;
|
||||
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
438
apps/server/tests/unit/lib/dependency-resolver.test.ts
Normal file
438
apps/server/tests/unit/lib/dependency-resolver.test.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveDependencies,
|
||||
areDependenciesSatisfied,
|
||||
getBlockingDependencies,
|
||||
type DependencyResolutionResult,
|
||||
} from "@/lib/dependency-resolver.js";
|
||||
import type { Feature } from "@/services/feature-loader.js";
|
||||
|
||||
// Helper to create test features
|
||||
function createFeature(
|
||||
id: string,
|
||||
options: {
|
||||
status?: string;
|
||||
priority?: number;
|
||||
dependencies?: string[];
|
||||
category?: string;
|
||||
description?: string;
|
||||
} = {}
|
||||
): Feature {
|
||||
return {
|
||||
id,
|
||||
category: options.category || "test",
|
||||
description: options.description || `Feature ${id}`,
|
||||
status: options.status || "backlog",
|
||||
priority: options.priority,
|
||||
dependencies: options.dependencies,
|
||||
};
|
||||
}
|
||||
|
||||
describe("dependency-resolver.ts", () => {
|
||||
describe("resolveDependencies", () => {
|
||||
it("should handle empty feature list", () => {
|
||||
const result = resolveDependencies([]);
|
||||
|
||||
expect(result.orderedFeatures).toEqual([]);
|
||||
expect(result.circularDependencies).toEqual([]);
|
||||
expect(result.missingDependencies.size).toBe(0);
|
||||
expect(result.blockedFeatures.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle features with no dependencies", () => {
|
||||
const features = [
|
||||
createFeature("f1", { priority: 1 }),
|
||||
createFeature("f2", { priority: 2 }),
|
||||
createFeature("f3", { priority: 3 }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.orderedFeatures).toHaveLength(3);
|
||||
expect(result.orderedFeatures[0].id).toBe("f1"); // Highest priority first
|
||||
expect(result.orderedFeatures[1].id).toBe("f2");
|
||||
expect(result.orderedFeatures[2].id).toBe("f3");
|
||||
expect(result.circularDependencies).toEqual([]);
|
||||
expect(result.missingDependencies.size).toBe(0);
|
||||
expect(result.blockedFeatures.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should order features by dependencies (simple chain)", () => {
|
||||
const features = [
|
||||
createFeature("f3", { dependencies: ["f2"] }),
|
||||
createFeature("f1"),
|
||||
createFeature("f2", { dependencies: ["f1"] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.orderedFeatures).toHaveLength(3);
|
||||
expect(result.orderedFeatures[0].id).toBe("f1");
|
||||
expect(result.orderedFeatures[1].id).toBe("f2");
|
||||
expect(result.orderedFeatures[2].id).toBe("f3");
|
||||
expect(result.circularDependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it("should respect priority within same dependency level", () => {
|
||||
const features = [
|
||||
createFeature("f1", { priority: 3, dependencies: ["base"] }),
|
||||
createFeature("f2", { priority: 1, dependencies: ["base"] }),
|
||||
createFeature("f3", { priority: 2, dependencies: ["base"] }),
|
||||
createFeature("base"),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.orderedFeatures[0].id).toBe("base");
|
||||
expect(result.orderedFeatures[1].id).toBe("f2"); // Priority 1
|
||||
expect(result.orderedFeatures[2].id).toBe("f3"); // Priority 2
|
||||
expect(result.orderedFeatures[3].id).toBe("f1"); // Priority 3
|
||||
});
|
||||
|
||||
it("should use default priority of 2 when not specified", () => {
|
||||
const features = [
|
||||
createFeature("f1", { priority: 1 }),
|
||||
createFeature("f2"), // No priority = default 2
|
||||
createFeature("f3", { priority: 3 }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.orderedFeatures[0].id).toBe("f1");
|
||||
expect(result.orderedFeatures[1].id).toBe("f2");
|
||||
expect(result.orderedFeatures[2].id).toBe("f3");
|
||||
});
|
||||
|
||||
it("should detect missing dependencies", () => {
|
||||
const features = [
|
||||
createFeature("f1", { dependencies: ["missing1", "missing2"] }),
|
||||
createFeature("f2", { dependencies: ["f1", "missing3"] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.missingDependencies.size).toBe(2);
|
||||
expect(result.missingDependencies.get("f1")).toEqual(["missing1", "missing2"]);
|
||||
expect(result.missingDependencies.get("f2")).toEqual(["missing3"]);
|
||||
expect(result.orderedFeatures).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should detect blocked features (incomplete dependencies)", () => {
|
||||
const features = [
|
||||
createFeature("f1", { status: "in_progress" }),
|
||||
createFeature("f2", { status: "backlog", dependencies: ["f1"] }),
|
||||
createFeature("f3", { status: "completed" }),
|
||||
createFeature("f4", { status: "backlog", dependencies: ["f3"] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.blockedFeatures.size).toBe(1);
|
||||
expect(result.blockedFeatures.get("f2")).toEqual(["f1"]);
|
||||
expect(result.blockedFeatures.has("f4")).toBe(false); // f3 is completed
|
||||
});
|
||||
|
||||
it("should not block features whose dependencies are verified", () => {
|
||||
const features = [
|
||||
createFeature("f1", { status: "verified" }),
|
||||
createFeature("f2", { status: "backlog", dependencies: ["f1"] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.blockedFeatures.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should detect circular dependencies (simple cycle)", () => {
|
||||
const features = [
|
||||
createFeature("f1", { dependencies: ["f2"] }),
|
||||
createFeature("f2", { dependencies: ["f1"] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.circularDependencies).toHaveLength(1);
|
||||
expect(result.circularDependencies[0]).toContain("f1");
|
||||
expect(result.circularDependencies[0]).toContain("f2");
|
||||
expect(result.orderedFeatures).toHaveLength(2); // Features still included
|
||||
});
|
||||
|
||||
it("should detect circular dependencies (multi-node cycle)", () => {
|
||||
const features = [
|
||||
createFeature("f1", { dependencies: ["f3"] }),
|
||||
createFeature("f2", { dependencies: ["f1"] }),
|
||||
createFeature("f3", { dependencies: ["f2"] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.circularDependencies.length).toBeGreaterThan(0);
|
||||
expect(result.orderedFeatures).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should handle mixed valid and circular dependencies", () => {
|
||||
const features = [
|
||||
createFeature("base"),
|
||||
createFeature("f1", { dependencies: ["base", "f2"] }),
|
||||
createFeature("f2", { dependencies: ["f1"] }), // Circular with f1
|
||||
createFeature("f3", { dependencies: ["base"] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.circularDependencies.length).toBeGreaterThan(0);
|
||||
expect(result.orderedFeatures[0].id).toBe("base");
|
||||
expect(result.orderedFeatures).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("should handle complex dependency graph", () => {
|
||||
const features = [
|
||||
createFeature("ui", { dependencies: ["api", "auth"], priority: 1 }),
|
||||
createFeature("api", { dependencies: ["db"], priority: 2 }),
|
||||
createFeature("auth", { dependencies: ["db"], priority: 1 }),
|
||||
createFeature("db", { priority: 1 }),
|
||||
createFeature("tests", { dependencies: ["ui"], priority: 3 }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
const order = result.orderedFeatures.map(f => f.id);
|
||||
|
||||
expect(order[0]).toBe("db");
|
||||
expect(order.indexOf("db")).toBeLessThan(order.indexOf("api"));
|
||||
expect(order.indexOf("db")).toBeLessThan(order.indexOf("auth"));
|
||||
expect(order.indexOf("api")).toBeLessThan(order.indexOf("ui"));
|
||||
expect(order.indexOf("auth")).toBeLessThan(order.indexOf("ui"));
|
||||
expect(order.indexOf("ui")).toBeLessThan(order.indexOf("tests"));
|
||||
expect(result.circularDependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle features with empty dependencies array", () => {
|
||||
const features = [
|
||||
createFeature("f1", { dependencies: [] }),
|
||||
createFeature("f2", { dependencies: [] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.orderedFeatures).toHaveLength(2);
|
||||
expect(result.circularDependencies).toEqual([]);
|
||||
expect(result.blockedFeatures.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should track multiple blocking dependencies", () => {
|
||||
const features = [
|
||||
createFeature("f1", { status: "in_progress" }),
|
||||
createFeature("f2", { status: "backlog" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.blockedFeatures.get("f3")).toEqual(["f1", "f2"]);
|
||||
});
|
||||
|
||||
it("should handle self-referencing dependency", () => {
|
||||
const features = [
|
||||
createFeature("f1", { dependencies: ["f1"] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.circularDependencies.length).toBeGreaterThan(0);
|
||||
expect(result.orderedFeatures).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("areDependenciesSatisfied", () => {
|
||||
it("should return true for feature with no dependencies", () => {
|
||||
const feature = createFeature("f1");
|
||||
const allFeatures = [feature];
|
||||
|
||||
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for feature with empty dependencies array", () => {
|
||||
const feature = createFeature("f1", { dependencies: [] });
|
||||
const allFeatures = [feature];
|
||||
|
||||
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when all dependencies are completed", () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "completed" }),
|
||||
createFeature("f2", { status: "completed" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
];
|
||||
|
||||
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when all dependencies are verified", () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "verified" }),
|
||||
createFeature("f2", { status: "verified" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
];
|
||||
|
||||
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when dependencies are mix of completed and verified", () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "completed" }),
|
||||
createFeature("f2", { status: "verified" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
];
|
||||
|
||||
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when any dependency is in_progress", () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "completed" }),
|
||||
createFeature("f2", { status: "in_progress" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
];
|
||||
|
||||
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when any dependency is in backlog", () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "completed" }),
|
||||
createFeature("f2", { status: "backlog" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
];
|
||||
|
||||
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when dependency is missing", () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "backlog", dependencies: ["missing"] }),
|
||||
];
|
||||
|
||||
expect(areDependenciesSatisfied(allFeatures[0], allFeatures)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when multiple dependencies are incomplete", () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "backlog" }),
|
||||
createFeature("f2", { status: "in_progress" }),
|
||||
createFeature("f3", { status: "waiting_approval" }),
|
||||
createFeature("f4", { status: "backlog", dependencies: ["f1", "f2", "f3"] }),
|
||||
];
|
||||
|
||||
expect(areDependenciesSatisfied(allFeatures[3], allFeatures)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBlockingDependencies", () => {
|
||||
it("should return empty array for feature with no dependencies", () => {
|
||||
const feature = createFeature("f1");
|
||||
const allFeatures = [feature];
|
||||
|
||||
expect(getBlockingDependencies(feature, allFeatures)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array for feature with empty dependencies array", () => {
|
||||
const feature = createFeature("f1", { dependencies: [] });
|
||||
const allFeatures = [feature];
|
||||
|
||||
expect(getBlockingDependencies(feature, allFeatures)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array when all dependencies are completed", () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "completed" }),
|
||||
createFeature("f2", { status: "completed" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
];
|
||||
|
||||
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array when all dependencies are verified", () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "verified" }),
|
||||
createFeature("f2", { status: "verified" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
];
|
||||
|
||||
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return blocking dependencies in backlog status", () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "backlog" }),
|
||||
createFeature("f2", { status: "completed" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
];
|
||||
|
||||
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(["f1"]);
|
||||
});
|
||||
|
||||
it("should return blocking dependencies in in_progress status", () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "in_progress" }),
|
||||
createFeature("f2", { status: "verified" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
];
|
||||
|
||||
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(["f1"]);
|
||||
});
|
||||
|
||||
it("should return blocking dependencies in waiting_approval status", () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "waiting_approval" }),
|
||||
createFeature("f2", { status: "completed" }),
|
||||
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||
];
|
||||
|
||||
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(["f1"]);
|
||||
});
|
||||
|
||||
it("should return all blocking dependencies", () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "backlog" }),
|
||||
createFeature("f2", { status: "in_progress" }),
|
||||
createFeature("f3", { status: "waiting_approval" }),
|
||||
createFeature("f4", { status: "completed" }),
|
||||
createFeature("f5", { status: "backlog", dependencies: ["f1", "f2", "f3", "f4"] }),
|
||||
];
|
||||
|
||||
const blocking = getBlockingDependencies(allFeatures[4], allFeatures);
|
||||
expect(blocking).toHaveLength(3);
|
||||
expect(blocking).toContain("f1");
|
||||
expect(blocking).toContain("f2");
|
||||
expect(blocking).toContain("f3");
|
||||
expect(blocking).not.toContain("f4");
|
||||
});
|
||||
|
||||
it("should handle missing dependencies", () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "backlog", dependencies: ["missing"] }),
|
||||
];
|
||||
|
||||
// Missing dependencies won't be in the blocking list since they don't exist
|
||||
expect(getBlockingDependencies(allFeatures[0], allFeatures)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle mix of completed, verified, and incomplete dependencies", () => {
|
||||
const allFeatures = [
|
||||
createFeature("f1", { status: "completed" }),
|
||||
createFeature("f2", { status: "verified" }),
|
||||
createFeature("f3", { status: "in_progress" }),
|
||||
createFeature("f4", { status: "backlog" }),
|
||||
createFeature("f5", { status: "backlog", dependencies: ["f1", "f2", "f3", "f4"] }),
|
||||
];
|
||||
|
||||
const blocking = getBlockingDependencies(allFeatures[4], allFeatures);
|
||||
expect(blocking).toHaveLength(2);
|
||||
expect(blocking).toContain("f3");
|
||||
expect(blocking).toContain("f4");
|
||||
});
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
FolderOpen,
|
||||
Folder,
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
ArrowLeft,
|
||||
HardDrive,
|
||||
CornerDownLeft,
|
||||
Clock,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -44,6 +46,44 @@ interface FileBrowserDialogProps {
|
||||
initialPath?: string;
|
||||
}
|
||||
|
||||
const RECENT_FOLDERS_KEY = "file-browser-recent-folders";
|
||||
const MAX_RECENT_FOLDERS = 5;
|
||||
|
||||
function getRecentFolders(): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const stored = localStorage.getItem(RECENT_FOLDERS_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function addRecentFolder(path: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const recent = getRecentFolders();
|
||||
// Remove if already exists, then add to front
|
||||
const filtered = recent.filter((p) => p !== path);
|
||||
const updated = [path, ...filtered].slice(0, MAX_RECENT_FOLDERS);
|
||||
localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(updated));
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}
|
||||
|
||||
function removeRecentFolder(path: string): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const recent = getRecentFolders();
|
||||
const updated = recent.filter((p) => p !== path);
|
||||
localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(updated));
|
||||
return updated;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function FileBrowserDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -60,8 +100,26 @@ export function FileBrowserDialog({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [warning, setWarning] = useState("");
|
||||
const [recentFolders, setRecentFolders] = useState<string[]>([]);
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load recent folders when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRecentFolders(getRecentFolders());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleRemoveRecent = useCallback((e: React.MouseEvent, path: string) => {
|
||||
e.stopPropagation();
|
||||
const updated = removeRecentFolder(path);
|
||||
setRecentFolders(updated);
|
||||
}, []);
|
||||
|
||||
const handleSelectRecent = useCallback((path: string) => {
|
||||
browseDirectory(path);
|
||||
}, []);
|
||||
|
||||
const browseDirectory = async (dirPath?: string) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
@@ -152,27 +210,34 @@ export function FileBrowserDialog({
|
||||
|
||||
const handleSelect = () => {
|
||||
if (currentPath) {
|
||||
addRecentFolder(currentPath);
|
||||
onSelect(currentPath);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to get folder name from path
|
||||
const getFolderName = (path: string) => {
|
||||
const parts = path.split(/[/\\]/).filter(Boolean);
|
||||
return parts[parts.length - 1] || path;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="pb-2">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FolderOpen className="w-5 h-5 text-brand-500" />
|
||||
<DialogContent className="bg-popover border-border max-w-3xl max-h-[85vh] overflow-hidden flex flex-col p-4">
|
||||
<DialogHeader className="pb-1">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<FolderOpen className="w-4 h-4 text-brand-500" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
<DialogDescription className="text-muted-foreground text-xs">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 min-h-[400px] flex-1 overflow-hidden py-2">
|
||||
<div className="flex flex-col gap-2 min-h-[350px] flex-1 overflow-hidden py-1">
|
||||
{/* Direct path input */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
type="text"
|
||||
@@ -180,7 +245,7 @@ export function FileBrowserDialog({
|
||||
value={pathInput}
|
||||
onChange={(e) => setPathInput(e.target.value)}
|
||||
onKeyDown={handlePathInputKeyDown}
|
||||
className="flex-1 font-mono text-sm"
|
||||
className="flex-1 font-mono text-xs h-8"
|
||||
data-testid="path-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -190,16 +255,46 @@ export function FileBrowserDialog({
|
||||
onClick={handleGoToPath}
|
||||
disabled={loading || !pathInput.trim()}
|
||||
data-testid="go-to-path-button"
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<CornerDownLeft className="w-4 h-4 mr-1" />
|
||||
<CornerDownLeft className="w-3.5 h-3.5 mr-1" />
|
||||
Go
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Recent folders */}
|
||||
{recentFolders.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>Recent:</span>
|
||||
</div>
|
||||
{recentFolders.map((folder) => (
|
||||
<button
|
||||
key={folder}
|
||||
onClick={() => handleSelectRecent(folder)}
|
||||
className="group flex items-center gap-1 h-6 px-2 text-xs bg-sidebar-accent/20 hover:bg-sidebar-accent/40 rounded border border-sidebar-border transition-colors"
|
||||
disabled={loading}
|
||||
title={folder}
|
||||
>
|
||||
<Folder className="w-3 h-3 text-brand-500 shrink-0" />
|
||||
<span className="truncate max-w-[120px]">{getFolderName(folder)}</span>
|
||||
<button
|
||||
onClick={(e) => handleRemoveRecent(e, folder)}
|
||||
className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
|
||||
title="Remove from recent"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drives selector (Windows only) */}
|
||||
{drives.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-2">
|
||||
<div className="flex flex-wrap gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-1">
|
||||
<HardDrive className="w-3 h-3" />
|
||||
<span>Drives:</span>
|
||||
</div>
|
||||
@@ -211,7 +306,7 @@ export function FileBrowserDialog({
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => handleSelectDrive(drive)}
|
||||
className="h-7 px-3 text-xs"
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={loading}
|
||||
>
|
||||
{drive.replace("\\", "")}
|
||||
@@ -221,57 +316,57 @@ export function FileBrowserDialog({
|
||||
)}
|
||||
|
||||
{/* Current path breadcrumb */}
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
|
||||
<div className="flex items-center gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleGoHome}
|
||||
className="h-7 px-2"
|
||||
className="h-6 px-1.5"
|
||||
disabled={loading}
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
<Home className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
{parentPath && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleGoToParent}
|
||||
className="h-7 px-2"
|
||||
className="h-6 px-1.5"
|
||||
disabled={loading}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1 font-mono text-sm truncate text-muted-foreground">
|
||||
<div className="flex-1 font-mono text-xs truncate text-muted-foreground">
|
||||
{currentPath || "Loading..."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Directory list */}
|
||||
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-lg">
|
||||
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-full p-8">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Loading directories...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center justify-center h-full p-8">
|
||||
<div className="text-sm text-destructive">{error}</div>
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<div className="text-xs text-destructive">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{warning && (
|
||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg mb-2">
|
||||
<div className="text-sm text-yellow-500">{warning}</div>
|
||||
<div className="p-2 bg-yellow-500/10 border border-yellow-500/30 rounded-md mb-1">
|
||||
<div className="text-xs text-yellow-500">{warning}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && !warning && directories.length === 0 && (
|
||||
<div className="flex items-center justify-center h-full p-8">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
No subdirectories found
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,29 +378,29 @@ export function FileBrowserDialog({
|
||||
<button
|
||||
key={dir.path}
|
||||
onClick={() => handleSelectDirectory(dir)}
|
||||
className="w-full flex items-center gap-3 p-3 hover:bg-sidebar-accent/10 transition-colors text-left group"
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-sidebar-accent/10 transition-colors text-left group"
|
||||
>
|
||||
<Folder className="w-5 h-5 text-brand-500 shrink-0" />
|
||||
<span className="flex-1 truncate text-sm">{dir.name}</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||
<Folder className="w-4 h-4 text-brand-500 shrink-0" />
|
||||
<span className="flex-1 truncate text-xs">{dir.name}</span>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Paste a full path above, or click on folders to navigate. Press
|
||||
Enter or click Go to jump to a path.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t border-border pt-4 gap-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
<DialogFooter className="border-t border-border pt-3 gap-2 mt-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSelect} disabled={!currentPath || loading}>
|
||||
<FolderOpen className="w-4 h-4 mr-2" />
|
||||
<Button size="sm" onClick={handleSelect} disabled={!currentPath || loading}>
|
||||
<FolderOpen className="w-3.5 h-3.5 mr-1.5" />
|
||||
Select Current Folder
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -35,7 +35,7 @@ import { DeleteWorktreeDialog } from "./board-view/dialogs/delete-worktree-dialo
|
||||
import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialog";
|
||||
import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog";
|
||||
import { CreateBranchDialog } from "./board-view/dialogs/create-branch-dialog";
|
||||
import { WorktreeSelector } from "./board-view/components";
|
||||
import { WorktreePanel } from "./board-view/worktree-panel";
|
||||
import { COLUMNS } from "./board-view/constants";
|
||||
import {
|
||||
useBoardFeatures,
|
||||
@@ -432,8 +432,8 @@ export function BoardView() {
|
||||
isMounted={isMounted}
|
||||
/>
|
||||
|
||||
{/* Worktree Selector */}
|
||||
<WorktreeSelector
|
||||
{/* Worktree Panel */}
|
||||
<WorktreePanel
|
||||
refreshTrigger={worktreeRefreshKey}
|
||||
projectPath={currentProject.path}
|
||||
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { KanbanCard } from "./kanban-card";
|
||||
export { KanbanColumn } from "./kanban-column";
|
||||
export { WorktreeSelector } from "./worktree-selector";
|
||||
|
||||
@@ -56,9 +56,11 @@ import {
|
||||
Brain,
|
||||
Wand2,
|
||||
Archive,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { CountUpTimer } from "@/components/ui/count-up-timer";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { getBlockingDependencies } from "@/lib/dependency-resolver";
|
||||
import {
|
||||
parseAgentContext,
|
||||
AgentTaskInfo,
|
||||
@@ -142,7 +144,15 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||
const { kanbanCardDetailLevel, useWorktrees } = useAppStore();
|
||||
const { kanbanCardDetailLevel, enableDependencyBlocking, features, useWorktrees } = useAppStore();
|
||||
|
||||
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
||||
const blockingDependencies = useMemo(() => {
|
||||
if (!enableDependencyBlocking || feature.status !== "backlog") {
|
||||
return [];
|
||||
}
|
||||
return getBlockingDependencies(feature, features);
|
||||
}, [enableDependencyBlocking, feature, features]);
|
||||
|
||||
const showSteps =
|
||||
kanbanCardDetailLevel === "standard" ||
|
||||
@@ -327,7 +337,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute px-2 py-1 text-sm font-bold rounded-md flex items-center justify-center z-10",
|
||||
"absolute px-2 py-1 h-8 text-sm font-bold rounded-md flex items-center justify-center z-10",
|
||||
"top-2 left-2 min-w-[36px]",
|
||||
feature.priority === 1 &&
|
||||
"bg-red-500/20 text-red-500 border-2 border-red-500/50",
|
||||
@@ -338,7 +348,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
)}
|
||||
data-testid={`priority-badge-${feature.id}`}
|
||||
>
|
||||
P{feature.priority}
|
||||
{feature.priority === 1 ? "H" : feature.priority === 2 ? "M" : "L"}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-xs">
|
||||
@@ -363,27 +373,24 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status badges row */}
|
||||
{(feature.skipTests || feature.error || isJustFinished) && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-2 z-10 flex items-center gap-1",
|
||||
feature.priority ? "top-11" : "top-2"
|
||||
)}
|
||||
>
|
||||
{/* Skip Tests (Manual) indicator badge */}
|
||||
{feature.skipTests && !feature.error && (
|
||||
{/* Skip Tests (Manual) indicator badge - positioned at top right */}
|
||||
{feature.skipTests && !feature.error && feature.status === "backlog" && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 bg-[var(--status-warning-bg)] border border-[var(--status-warning)]/40 text-[var(--status-warning)]"
|
||||
className={cn(
|
||||
"absolute px-2 py-1 h-8 text-sm font-bold rounded-md flex items-center justify-center z-10",
|
||||
"min-w-[36px]",
|
||||
"top-2 right-2",
|
||||
"bg-[var(--status-warning-bg)] border-2 border-[var(--status-warning)]/50 text-[var(--status-warning)]"
|
||||
)}
|
||||
data-testid={`skip-tests-badge-${feature.id}`}
|
||||
>
|
||||
<Hand className="w-3 h-3" />
|
||||
<Hand className="w-4 h-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-xs">
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
<p>Manual verification required</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -396,10 +403,15 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 bg-[var(--status-error-bg)] border border-[var(--status-error)]/40 text-[var(--status-error)]"
|
||||
className={cn(
|
||||
"absolute px-2 py-1 text-[11px] font-medium rounded-md flex items-center justify-center z-10",
|
||||
"min-w-[36px]",
|
||||
feature.priority ? "top-11 left-2" : "top-2 left-2",
|
||||
"bg-[var(--status-error-bg)] border border-[var(--status-error)]/40 text-[var(--status-error)]"
|
||||
)}
|
||||
data-testid={`error-badge-${feature.id}`}
|
||||
>
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-xs max-w-[250px]">
|
||||
@@ -409,18 +421,51 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Blocked by dependencies badge - positioned at top right */}
|
||||
{blockingDependencies.length > 0 && !feature.error && !feature.skipTests && feature.status === "backlog" && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute px-2 py-1 h-8 text-sm font-bold rounded-md flex items-center justify-center z-10",
|
||||
"min-w-[36px]",
|
||||
"top-2 right-2",
|
||||
"bg-orange-500/20 border-2 border-orange-500/50 text-orange-500"
|
||||
)}
|
||||
data-testid={`blocked-badge-${feature.id}`}
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs max-w-[250px]">
|
||||
<p className="font-medium mb-1">Blocked by {blockingDependencies.length} incomplete {blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{blockingDependencies.map(depId => {
|
||||
const dep = features.find(f => f.id === depId);
|
||||
return dep?.description || depId;
|
||||
}).join(', ')}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Just Finished indicator badge */}
|
||||
{isJustFinished && (
|
||||
<div
|
||||
className="px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 bg-[var(--status-success-bg)] border border-[var(--status-success)]/40 text-[var(--status-success)] animate-pulse"
|
||||
className={cn(
|
||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
|
||||
feature.priority ? "top-11 left-2" : "top-2 left-2",
|
||||
"bg-[var(--status-success-bg)] border border-[var(--status-success)]/40 text-[var(--status-success)]",
|
||||
"animate-pulse"
|
||||
)}
|
||||
data-testid={`just-finished-badge-${feature.id}`}
|
||||
title="Agent just finished working on this feature"
|
||||
>
|
||||
<Sparkles className="w-3 h-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader
|
||||
className={cn(
|
||||
@@ -446,7 +491,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
</div>
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === "backlog" && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className="absolute bottom-1 right-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -465,6 +510,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
{!isCurrentAutoTask &&
|
||||
(feature.status === "waiting_approval" ||
|
||||
feature.status === "verified") && (
|
||||
<>
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -502,6 +548,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<FileText className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -519,8 +567,10 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === "in_progress" && (
|
||||
<>
|
||||
<div className="absolute top-2 right-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -560,20 +610,26 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
View Logs
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick(e as unknown as React.MouseEvent);
|
||||
}}
|
||||
data-testid={`delete-feature-${feature.id}`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick(e);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`delete-feature-${feature.id}`}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-start gap-2">
|
||||
{isDraggable && (
|
||||
|
||||
@@ -1,832 +0,0 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
GitBranch,
|
||||
Plus,
|
||||
Trash2,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
GitCommit,
|
||||
GitPullRequest,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Upload,
|
||||
GitBranchPlus,
|
||||
Check,
|
||||
Search,
|
||||
Play,
|
||||
Square,
|
||||
Globe,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { cn, pathsEqual, normalizePath } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
isCurrent: boolean; // Is this the currently checked out branch?
|
||||
hasWorktree: boolean; // Does this branch have an active worktree?
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
interface BranchInfo {
|
||||
name: string;
|
||||
isCurrent: boolean;
|
||||
isRemote: boolean;
|
||||
}
|
||||
|
||||
interface DevServerInfo {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface FeatureInfo {
|
||||
id: string;
|
||||
worktreePath?: string;
|
||||
branchName?: string; // Used as fallback to determine which worktree the spinner should show on
|
||||
}
|
||||
|
||||
interface WorktreeSelectorProps {
|
||||
projectPath: string;
|
||||
onCreateWorktree: () => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
runningFeatureIds?: string[];
|
||||
features?: FeatureInfo[];
|
||||
/** Increment this to trigger a refresh without unmounting the component */
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
export function WorktreeSelector({
|
||||
projectPath,
|
||||
onCreateWorktree,
|
||||
onDeleteWorktree,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onCreateBranch,
|
||||
runningFeatureIds = [],
|
||||
features = [],
|
||||
refreshTrigger = 0,
|
||||
}: WorktreeSelectorProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
const [isSwitching, setIsSwitching] = useState(false);
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
|
||||
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
|
||||
const [branches, setBranches] = useState<BranchInfo[]>([]);
|
||||
const [aheadCount, setAheadCount] = useState(0);
|
||||
const [behindCount, setBehindCount] = useState(0);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [branchFilter, setBranchFilter] = useState("");
|
||||
const [runningDevServers, setRunningDevServers] = useState<
|
||||
Map<string, DevServerInfo>
|
||||
>(new Map());
|
||||
const [defaultEditorName, setDefaultEditorName] = useState<string>("Editor");
|
||||
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
|
||||
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
|
||||
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
|
||||
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
|
||||
|
||||
const fetchWorktrees = useCallback(async () => {
|
||||
if (!projectPath) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listAll) {
|
||||
console.warn("Worktree API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.listAll(projectPath, true);
|
||||
if (result.success && result.worktrees) {
|
||||
setWorktrees(result.worktrees);
|
||||
setWorktreesInStore(projectPath, result.worktrees);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch worktrees:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectPath, setWorktreesInStore]);
|
||||
|
||||
const fetchDevServers = useCallback(async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listDevServers) {
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.listDevServers();
|
||||
if (result.success && result.result?.servers) {
|
||||
const serversMap = new Map<string, DevServerInfo>();
|
||||
for (const server of result.result.servers) {
|
||||
serversMap.set(server.worktreePath, server);
|
||||
}
|
||||
setRunningDevServers(serversMap);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch dev servers:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchDefaultEditor = useCallback(async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.getDefaultEditor) {
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.getDefaultEditor();
|
||||
if (result.success && result.result?.editorName) {
|
||||
setDefaultEditorName(result.result.editorName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch default editor:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchBranches = useCallback(async (worktreePath: string) => {
|
||||
setIsLoadingBranches(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listBranches) {
|
||||
console.warn("List branches API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.listBranches(worktreePath);
|
||||
if (result.success && result.result) {
|
||||
setBranches(result.result.branches);
|
||||
setAheadCount(result.result.aheadCount || 0);
|
||||
setBehindCount(result.result.behindCount || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch branches:", error);
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorktrees();
|
||||
fetchDevServers();
|
||||
fetchDefaultEditor();
|
||||
}, [fetchWorktrees, fetchDevServers, fetchDefaultEditor]);
|
||||
|
||||
// Refresh when refreshTrigger changes (but skip the initial render)
|
||||
useEffect(() => {
|
||||
if (refreshTrigger > 0) {
|
||||
fetchWorktrees();
|
||||
}
|
||||
}, [refreshTrigger, fetchWorktrees]);
|
||||
|
||||
// Initialize selection to main if not set OR if the stored worktree no longer exists
|
||||
// This handles stale data (e.g., a worktree that was deleted)
|
||||
useEffect(() => {
|
||||
if (worktrees.length > 0) {
|
||||
const currentPath = currentWorktree?.path;
|
||||
|
||||
// Check if the currently selected worktree still exists
|
||||
// null path means main (which always exists if worktrees has items)
|
||||
// Non-null path means we need to verify it exists in the worktrees list
|
||||
const currentWorktreeExists = currentPath === null
|
||||
? true
|
||||
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
|
||||
|
||||
// Reset to main if:
|
||||
// 1. No worktree is set (currentWorktree is null/undefined)
|
||||
// 2. Current worktree has a path that doesn't exist in the list (stale data)
|
||||
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
|
||||
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||
const mainBranch = mainWorktree?.branch || "main";
|
||||
setCurrentWorktree(projectPath, null, mainBranch); // null = main worktree
|
||||
}
|
||||
}
|
||||
}, [worktrees, currentWorktree, projectPath, setCurrentWorktree]);
|
||||
|
||||
const handleSelectWorktree = async (worktree: WorktreeInfo) => {
|
||||
// Simply select the worktree in the UI with both path and branch
|
||||
setCurrentWorktree(
|
||||
projectPath,
|
||||
worktree.isMain ? null : worktree.path,
|
||||
worktree.branch
|
||||
);
|
||||
};
|
||||
|
||||
const handleStartDevServer = async (worktree: WorktreeInfo) => {
|
||||
if (isStartingDevServer) return;
|
||||
setIsStartingDevServer(true);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.startDevServer) {
|
||||
toast.error("Start dev server API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use projectPath for main, worktree.path for others
|
||||
const targetPath = worktree.isMain ? projectPath : worktree.path;
|
||||
const result = await api.worktree.startDevServer(projectPath, targetPath);
|
||||
|
||||
if (result.success && result.result) {
|
||||
// Update running servers map (normalize path for cross-platform compatibility)
|
||||
setRunningDevServers((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(normalizePath(targetPath), {
|
||||
worktreePath: result.result!.worktreePath,
|
||||
port: result.result!.port,
|
||||
url: result.result!.url,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
toast.success(`Dev server started on port ${result.result.port}`);
|
||||
} else {
|
||||
toast.error(result.error || "Failed to start dev server");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Start dev server failed:", error);
|
||||
toast.error("Failed to start dev server");
|
||||
} finally {
|
||||
setIsStartingDevServer(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopDevServer = async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.stopDevServer) {
|
||||
toast.error("Stop dev server API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use projectPath for main, worktree.path for others
|
||||
const targetPath = worktree.isMain ? projectPath : worktree.path;
|
||||
const result = await api.worktree.stopDevServer(targetPath);
|
||||
|
||||
if (result.success) {
|
||||
// Update running servers map (normalize path for cross-platform compatibility)
|
||||
setRunningDevServers((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(normalizePath(targetPath));
|
||||
return next;
|
||||
});
|
||||
toast.success(result.result?.message || "Dev server stopped");
|
||||
} else {
|
||||
toast.error(result.error || "Failed to stop dev server");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Stop dev server failed:", error);
|
||||
toast.error("Failed to stop dev server");
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDevServerUrl = (worktree: WorktreeInfo) => {
|
||||
const targetPath = worktree.isMain ? projectPath : worktree.path;
|
||||
const serverInfo = runningDevServers.get(targetPath);
|
||||
if (serverInfo) {
|
||||
window.open(serverInfo.url, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to get the path key for a worktree (for looking up in runningDevServers)
|
||||
// Normalizes path for cross-platform compatibility
|
||||
const getWorktreeKey = (worktree: WorktreeInfo) => {
|
||||
const path = worktree.isMain ? projectPath : worktree.path;
|
||||
return path ? normalizePath(path) : path;
|
||||
};
|
||||
|
||||
// Helper to check if a worktree has running features
|
||||
const hasRunningFeatures = (worktree: WorktreeInfo) => {
|
||||
if (runningFeatureIds.length === 0) return false;
|
||||
|
||||
const worktreeKey = getWorktreeKey(worktree);
|
||||
|
||||
// Check if any running feature belongs to this worktree
|
||||
return runningFeatureIds.some((featureId) => {
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
if (!feature) return false;
|
||||
|
||||
// First, check if worktreePath is set and matches
|
||||
// Use pathsEqual for cross-platform compatibility (Windows uses backslashes)
|
||||
if (feature.worktreePath) {
|
||||
if (worktree.isMain) {
|
||||
// Feature has worktreePath - show on main only if it matches projectPath
|
||||
return pathsEqual(feature.worktreePath, projectPath);
|
||||
}
|
||||
// For non-main worktrees, check if worktreePath matches
|
||||
return pathsEqual(feature.worktreePath, worktreeKey);
|
||||
}
|
||||
|
||||
// If worktreePath is not set, use branchName as fallback
|
||||
if (feature.branchName) {
|
||||
// Feature has a branchName - show spinner on the worktree with matching branch
|
||||
return worktree.branch === feature.branchName;
|
||||
}
|
||||
|
||||
// No worktreePath and no branchName - default to main
|
||||
return worktree.isMain;
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenInEditor = async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.openInEditor) {
|
||||
console.warn("Open in editor API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.openInEditor(worktree.path);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
} else if (result.error) {
|
||||
toast.error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Open in editor failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchBranch = async (
|
||||
worktree: WorktreeInfo,
|
||||
branchName: string
|
||||
) => {
|
||||
if (isSwitching || branchName === worktree.branch) return;
|
||||
setIsSwitching(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.switchBranch) {
|
||||
toast.error("Switch branch API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.switchBranch(worktree.path, branchName);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
// Refresh worktrees to get updated branch info
|
||||
fetchWorktrees();
|
||||
} else {
|
||||
toast.error(result.error || "Failed to switch branch");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Switch branch failed:", error);
|
||||
toast.error("Failed to switch branch");
|
||||
} finally {
|
||||
setIsSwitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePull = async (worktree: WorktreeInfo) => {
|
||||
if (isPulling) return;
|
||||
setIsPulling(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.pull) {
|
||||
toast.error("Pull API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.pull(worktree.path);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
// Refresh worktrees to get updated status
|
||||
fetchWorktrees();
|
||||
} else {
|
||||
toast.error(result.error || "Failed to pull latest changes");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Pull failed:", error);
|
||||
toast.error("Failed to pull latest changes");
|
||||
} finally {
|
||||
setIsPulling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePush = async (worktree: WorktreeInfo) => {
|
||||
if (isPushing) return;
|
||||
setIsPushing(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.push) {
|
||||
toast.error("Push API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.push(worktree.path);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
// Refresh to update ahead/behind counts
|
||||
fetchBranches(worktree.path);
|
||||
fetchWorktrees();
|
||||
} else {
|
||||
toast.error(result.error || "Failed to push changes");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Push failed:", error);
|
||||
toast.error("Failed to push changes");
|
||||
} finally {
|
||||
setIsPushing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// The "selected" worktree is based on UI state, not git's current branch
|
||||
// currentWorktree.path is null for main, or the worktree path for others
|
||||
const currentWorktreePath = currentWorktree?.path ?? null;
|
||||
const selectedWorktree = currentWorktreePath
|
||||
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
|
||||
: worktrees.find((w) => w.isMain);
|
||||
|
||||
// Render a worktree tab with branch selector (for main) and actions dropdown
|
||||
const renderWorktreeTab = (worktree: WorktreeInfo) => {
|
||||
// Selection is based on UI state, not git's current branch
|
||||
// Default to main selected if currentWorktree is null/undefined or path is null
|
||||
const isSelected = worktree.isMain
|
||||
? currentWorktree === null ||
|
||||
currentWorktree === undefined ||
|
||||
currentWorktree.path === null
|
||||
: pathsEqual(worktree.path, currentWorktreePath);
|
||||
|
||||
const isRunning = hasRunningFeatures(worktree);
|
||||
|
||||
return (
|
||||
<div key={worktree.path} className="flex items-center">
|
||||
{/* Main branch: clickable button + separate branch switch dropdown */}
|
||||
{worktree.isMain ? (
|
||||
<>
|
||||
{/* Clickable button to select/preview main */}
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||
)}
|
||||
onClick={() => handleSelectWorktree(worktree)}
|
||||
disabled={isActivating}
|
||||
title="Click to preview main"
|
||||
>
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{isActivating && !isRunning && (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
)}
|
||||
{worktree.branch}
|
||||
{worktree.hasChanges && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
{worktree.changedFilesCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
{/* Branch switch dropdown button */}
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
fetchBranches(worktree.path);
|
||||
setBranchFilter("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0 rounded-none border-r-0",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||
)}
|
||||
title="Switch branch"
|
||||
>
|
||||
<GitBranch className="w-3 h-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuLabel className="text-xs">
|
||||
Switch Branch
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Search input */}
|
||||
<div className="px-2 py-1.5">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filter branches..."
|
||||
value={branchFilter}
|
||||
onChange={(e) => setBranchFilter(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyUp={(e) => e.stopPropagation()}
|
||||
onKeyPress={(e) => e.stopPropagation()}
|
||||
className="h-7 pl-7 text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="max-h-[250px] overflow-y-auto">
|
||||
{isLoadingBranches ? (
|
||||
<DropdownMenuItem disabled className="text-xs">
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
|
||||
Loading branches...
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
(() => {
|
||||
const filteredBranches = branches.filter((b) =>
|
||||
b.name
|
||||
.toLowerCase()
|
||||
.includes(branchFilter.toLowerCase())
|
||||
);
|
||||
if (filteredBranches.length === 0) {
|
||||
return (
|
||||
<DropdownMenuItem disabled className="text-xs">
|
||||
{branchFilter
|
||||
? "No matching branches"
|
||||
: "No branches found"}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
return filteredBranches.map((branch) => (
|
||||
<DropdownMenuItem
|
||||
key={branch.name}
|
||||
onClick={() =>
|
||||
handleSwitchBranch(worktree, branch.name)
|
||||
}
|
||||
disabled={
|
||||
isSwitching || branch.name === worktree.branch
|
||||
}
|
||||
className="text-xs font-mono"
|
||||
>
|
||||
{branch.name === worktree.branch ? (
|
||||
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
|
||||
) : (
|
||||
<span className="w-3.5 mr-2 flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{branch.name}</span>
|
||||
</DropdownMenuItem>
|
||||
));
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onCreateBranch(worktree)}
|
||||
className="text-xs"
|
||||
>
|
||||
<GitBranchPlus className="w-3.5 h-3.5 mr-2" />
|
||||
Create New Branch...
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
) : (
|
||||
// Non-main branches - click to switch to this branch
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
||||
!worktree.hasWorktree && !isSelected && "opacity-70" // Dim if no active worktree
|
||||
)}
|
||||
onClick={() => handleSelectWorktree(worktree)}
|
||||
disabled={isActivating}
|
||||
title={
|
||||
worktree.hasWorktree
|
||||
? "Click to switch to this worktree's branch"
|
||||
: "Click to switch to this branch"
|
||||
}
|
||||
>
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{isActivating && !isRunning && (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
)}
|
||||
{worktree.branch}
|
||||
{worktree.hasChanges && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
{worktree.changedFilesCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Dev server indicator */}
|
||||
{runningDevServers.has(getWorktreeKey(worktree)) && (
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0 rounded-none border-r-0",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
||||
"text-green-500"
|
||||
)}
|
||||
onClick={() => handleOpenDevServerUrl(worktree)}
|
||||
title={`Open dev server (port ${
|
||||
runningDevServers.get(getWorktreeKey(worktree))?.port
|
||||
})`}
|
||||
>
|
||||
<Globe className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Actions dropdown */}
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
fetchBranches(worktree.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0 rounded-l-none",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="w-3 h-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
{/* Dev server controls */}
|
||||
{runningDevServers.has(getWorktreeKey(worktree)) ? (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
Dev Server Running (:
|
||||
{runningDevServers.get(getWorktreeKey(worktree))?.port})
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleOpenDevServerUrl(worktree)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5 mr-2" />
|
||||
Open in Browser
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStopDevServer(worktree)}
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5 mr-2" />
|
||||
Stop Dev Server
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStartDevServer(worktree)}
|
||||
disabled={isStartingDevServer}
|
||||
className="text-xs"
|
||||
>
|
||||
<Play
|
||||
className={cn(
|
||||
"w-3.5 h-3.5 mr-2",
|
||||
isStartingDevServer && "animate-pulse"
|
||||
)}
|
||||
/>
|
||||
{isStartingDevServer ? "Starting..." : "Start Dev Server"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{/* Pull option */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePull(worktree)}
|
||||
disabled={isPulling}
|
||||
className="text-xs"
|
||||
>
|
||||
<Download
|
||||
className={cn("w-3.5 h-3.5 mr-2", isPulling && "animate-pulse")}
|
||||
/>
|
||||
{isPulling ? "Pulling..." : "Pull"}
|
||||
{behindCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||
{behindCount} behind
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{/* Push option */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePush(worktree)}
|
||||
disabled={isPushing || aheadCount === 0}
|
||||
className="text-xs"
|
||||
>
|
||||
<Upload
|
||||
className={cn("w-3.5 h-3.5 mr-2", isPushing && "animate-pulse")}
|
||||
/>
|
||||
{isPushing ? "Pushing..." : "Push"}
|
||||
{aheadCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||
{aheadCount} ahead
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Open in editor */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleOpenInEditor(worktree)}
|
||||
className="text-xs"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-2" />
|
||||
Open in {defaultEditorName}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Commit changes */}
|
||||
{worktree.hasChanges && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onCommit(worktree)}
|
||||
className="text-xs"
|
||||
>
|
||||
<GitCommit className="w-3.5 h-3.5 mr-2" />
|
||||
Commit Changes
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Show PR option if not on main branch, or if on main with changes */}
|
||||
{(worktree.branch !== "main" || worktree.hasChanges) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onCreatePR(worktree)}
|
||||
className="text-xs"
|
||||
>
|
||||
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
||||
Create Pull Request
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Only show delete for non-main worktrees */}
|
||||
{!worktree.isMain && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDeleteWorktree(worktree)}
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-2" />
|
||||
Delete Worktree
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Don't render the worktree selector if the feature is disabled
|
||||
if (!useWorktreesEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
|
||||
|
||||
{/* Worktree Tabs */}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{worktrees.map((worktree) => renderWorktreeTab(worktree))}
|
||||
|
||||
{/* Add Worktree Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCreateWorktree}
|
||||
title="Create new worktree"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Refresh Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={fetchWorktrees}
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { getElectronAPI } from "@/lib/electron";
|
||||
import { toast } from "sonner";
|
||||
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||
import { truncateDescription } from "@/lib/utils";
|
||||
import { getBlockingDependencies } from "@/lib/dependency-resolver";
|
||||
|
||||
interface UseBoardActionsProps {
|
||||
currentProject: { path: string; id: string } | null;
|
||||
@@ -74,6 +75,7 @@ export function useBoardActions({
|
||||
removeFeature,
|
||||
moveFeature,
|
||||
useWorktrees,
|
||||
enableDependencyBlocking,
|
||||
} = useAppStore();
|
||||
const autoMode = useAutoMode();
|
||||
|
||||
@@ -344,6 +346,21 @@ export function useBoardActions({
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for blocking dependencies and show warning if enabled
|
||||
if (enableDependencyBlocking) {
|
||||
const blockingDeps = getBlockingDependencies(feature, features);
|
||||
if (blockingDeps.length > 0) {
|
||||
const depDescriptions = blockingDeps.map(depId => {
|
||||
const dep = features.find(f => f.id === depId);
|
||||
return dep ? truncateDescription(dep.description, 40) : depId;
|
||||
}).join(", ");
|
||||
|
||||
toast.warning("Starting feature with incomplete dependencies", {
|
||||
description: `This feature depends on: ${depDescriptions}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updates = {
|
||||
status: "in_progress" as const,
|
||||
startedAt: new Date().toISOString(),
|
||||
@@ -355,7 +372,7 @@ export function useBoardActions({
|
||||
await handleRunFeature(feature);
|
||||
return true;
|
||||
},
|
||||
[autoMode, updateFeature, persistFeatureUpdate, handleRunFeature]
|
||||
[autoMode, enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
|
||||
);
|
||||
|
||||
const handleVerifyFeature = useCallback(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { Feature } from "@/store/app-store";
|
||||
import { resolveDependencies } from "@/lib/dependency-resolver";
|
||||
import { pathsEqual } from "@/lib/utils";
|
||||
|
||||
type ColumnId = Feature["status"];
|
||||
@@ -105,12 +106,13 @@ export function useBoardColumnFeatures({
|
||||
}
|
||||
});
|
||||
|
||||
// Sort backlog by priority: 1 (high) -> 2 (medium) -> 3 (low) -> no priority
|
||||
map.backlog.sort((a, b) => {
|
||||
const aPriority = a.priority ?? 999; // Features without priority go last
|
||||
const bPriority = b.priority ?? 999;
|
||||
return aPriority - bPriority;
|
||||
});
|
||||
// Apply dependency-aware sorting to backlog
|
||||
// This ensures features appear in dependency order (dependencies before dependents)
|
||||
// Within the same dependency level, features are sorted by priority
|
||||
if (map.backlog.length > 0) {
|
||||
const { orderedFeatures } = resolveDependencies(map.backlog);
|
||||
map.backlog = orderedFeatures;
|
||||
}
|
||||
|
||||
return map;
|
||||
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
GitBranch,
|
||||
RefreshCw,
|
||||
GitBranchPlus,
|
||||
Check,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WorktreeInfo, BranchInfo } from "../types";
|
||||
|
||||
interface BranchSwitchDropdownProps {
|
||||
worktree: WorktreeInfo;
|
||||
isSelected: boolean;
|
||||
branches: BranchInfo[];
|
||||
filteredBranches: BranchInfo[];
|
||||
branchFilter: string;
|
||||
isLoadingBranches: boolean;
|
||||
isSwitching: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onFilterChange: (value: string) => void;
|
||||
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
}
|
||||
|
||||
export function BranchSwitchDropdown({
|
||||
worktree,
|
||||
isSelected,
|
||||
filteredBranches,
|
||||
branchFilter,
|
||||
isLoadingBranches,
|
||||
isSwitching,
|
||||
onOpenChange,
|
||||
onFilterChange,
|
||||
onSwitchBranch,
|
||||
onCreateBranch,
|
||||
}: BranchSwitchDropdownProps) {
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0 rounded-none border-r-0",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||
)}
|
||||
title="Switch branch"
|
||||
>
|
||||
<GitBranch className="w-3 h-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuLabel className="text-xs">Switch Branch</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filter branches..."
|
||||
value={branchFilter}
|
||||
onChange={(e) => onFilterChange(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyUp={(e) => e.stopPropagation()}
|
||||
onKeyPress={(e) => e.stopPropagation()}
|
||||
className="h-7 pl-7 text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="max-h-[250px] overflow-y-auto">
|
||||
{isLoadingBranches ? (
|
||||
<DropdownMenuItem disabled className="text-xs">
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
|
||||
Loading branches...
|
||||
</DropdownMenuItem>
|
||||
) : filteredBranches.length === 0 ? (
|
||||
<DropdownMenuItem disabled className="text-xs">
|
||||
{branchFilter ? "No matching branches" : "No branches found"}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
filteredBranches.map((branch) => (
|
||||
<DropdownMenuItem
|
||||
key={branch.name}
|
||||
onClick={() => onSwitchBranch(worktree, branch.name)}
|
||||
disabled={isSwitching || branch.name === worktree.branch}
|
||||
className="text-xs font-mono"
|
||||
>
|
||||
{branch.name === worktree.branch ? (
|
||||
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
|
||||
) : (
|
||||
<span className="w-3.5 mr-2 flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{branch.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onCreateBranch(worktree)}
|
||||
className="text-xs"
|
||||
>
|
||||
<GitBranchPlus className="w-3.5 h-3.5 mr-2" />
|
||||
Create New Branch...
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { BranchSwitchDropdown } from "./branch-switch-dropdown";
|
||||
export { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
|
||||
export { WorktreeTab } from "./worktree-tab";
|
||||
@@ -0,0 +1,193 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Trash2,
|
||||
MoreHorizontal,
|
||||
GitCommit,
|
||||
GitPullRequest,
|
||||
ExternalLink,
|
||||
Download,
|
||||
Upload,
|
||||
Play,
|
||||
Square,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WorktreeInfo, DevServerInfo } from "../types";
|
||||
|
||||
interface WorktreeActionsDropdownProps {
|
||||
worktree: WorktreeInfo;
|
||||
isSelected: boolean;
|
||||
defaultEditorName: string;
|
||||
aheadCount: number;
|
||||
behindCount: number;
|
||||
isPulling: boolean;
|
||||
isPushing: boolean;
|
||||
isStartingDevServer: boolean;
|
||||
isDevServerRunning: boolean;
|
||||
devServerInfo?: DevServerInfo;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||
}
|
||||
|
||||
export function WorktreeActionsDropdown({
|
||||
worktree,
|
||||
isSelected,
|
||||
defaultEditorName,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
isPulling,
|
||||
isPushing,
|
||||
isStartingDevServer,
|
||||
isDevServerRunning,
|
||||
devServerInfo,
|
||||
onOpenChange,
|
||||
onPull,
|
||||
onPush,
|
||||
onOpenInEditor,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onDeleteWorktree,
|
||||
onStartDevServer,
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
}: WorktreeActionsDropdownProps) {
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0 rounded-l-none",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="w-3 h-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
{isDevServerRunning ? (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
Dev Server Running (:{devServerInfo?.port})
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenDevServerUrl(worktree)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5 mr-2" />
|
||||
Open in Browser
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onStopDevServer(worktree)}
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5 mr-2" />
|
||||
Stop Dev Server
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onStartDevServer(worktree)}
|
||||
disabled={isStartingDevServer}
|
||||
className="text-xs"
|
||||
>
|
||||
<Play
|
||||
className={cn(
|
||||
"w-3.5 h-3.5 mr-2",
|
||||
isStartingDevServer && "animate-pulse"
|
||||
)}
|
||||
/>
|
||||
{isStartingDevServer ? "Starting..." : "Start Dev Server"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => onPull(worktree)}
|
||||
disabled={isPulling}
|
||||
className="text-xs"
|
||||
>
|
||||
<Download
|
||||
className={cn("w-3.5 h-3.5 mr-2", isPulling && "animate-pulse")}
|
||||
/>
|
||||
{isPulling ? "Pulling..." : "Pull"}
|
||||
{behindCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||
{behindCount} behind
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onPush(worktree)}
|
||||
disabled={isPushing || aheadCount === 0}
|
||||
className="text-xs"
|
||||
>
|
||||
<Upload
|
||||
className={cn("w-3.5 h-3.5 mr-2", isPushing && "animate-pulse")}
|
||||
/>
|
||||
{isPushing ? "Pushing..." : "Push"}
|
||||
{aheadCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||
{aheadCount} ahead
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenInEditor(worktree)}
|
||||
className="text-xs"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-2" />
|
||||
Open in {defaultEditorName}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{worktree.hasChanges && (
|
||||
<DropdownMenuItem onClick={() => onCommit(worktree)} className="text-xs">
|
||||
<GitCommit className="w-3.5 h-3.5 mr-2" />
|
||||
Commit Changes
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(worktree.branch !== "main" || worktree.hasChanges) && (
|
||||
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
|
||||
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
||||
Create Pull Request
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!worktree.isMain && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDeleteWorktree(worktree)}
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-2" />
|
||||
Delete Worktree
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, Globe, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WorktreeInfo, BranchInfo, DevServerInfo } from "../types";
|
||||
import { BranchSwitchDropdown } from "./branch-switch-dropdown";
|
||||
import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
|
||||
|
||||
interface WorktreeTabProps {
|
||||
worktree: WorktreeInfo;
|
||||
isSelected: boolean;
|
||||
isRunning: boolean;
|
||||
isActivating: boolean;
|
||||
isDevServerRunning: boolean;
|
||||
devServerInfo?: DevServerInfo;
|
||||
defaultEditorName: string;
|
||||
branches: BranchInfo[];
|
||||
filteredBranches: BranchInfo[];
|
||||
branchFilter: string;
|
||||
isLoadingBranches: boolean;
|
||||
isSwitching: boolean;
|
||||
isPulling: boolean;
|
||||
isPushing: boolean;
|
||||
isStartingDevServer: boolean;
|
||||
aheadCount: number;
|
||||
behindCount: number;
|
||||
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
||||
onBranchDropdownOpenChange: (open: boolean) => void;
|
||||
onActionsDropdownOpenChange: (open: boolean) => void;
|
||||
onBranchFilterChange: (value: string) => void;
|
||||
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||
}
|
||||
|
||||
export function WorktreeTab({
|
||||
worktree,
|
||||
isSelected,
|
||||
isRunning,
|
||||
isActivating,
|
||||
isDevServerRunning,
|
||||
devServerInfo,
|
||||
defaultEditorName,
|
||||
branches,
|
||||
filteredBranches,
|
||||
branchFilter,
|
||||
isLoadingBranches,
|
||||
isSwitching,
|
||||
isPulling,
|
||||
isPushing,
|
||||
isStartingDevServer,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
onSelectWorktree,
|
||||
onBranchDropdownOpenChange,
|
||||
onActionsDropdownOpenChange,
|
||||
onBranchFilterChange,
|
||||
onSwitchBranch,
|
||||
onCreateBranch,
|
||||
onPull,
|
||||
onPush,
|
||||
onOpenInEditor,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onDeleteWorktree,
|
||||
onStartDevServer,
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
}: WorktreeTabProps) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{worktree.isMain ? (
|
||||
<>
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||
)}
|
||||
onClick={() => onSelectWorktree(worktree)}
|
||||
disabled={isActivating}
|
||||
title="Click to preview main"
|
||||
>
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{isActivating && !isRunning && (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
)}
|
||||
{worktree.branch}
|
||||
{worktree.hasChanges && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
{worktree.changedFilesCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<BranchSwitchDropdown
|
||||
worktree={worktree}
|
||||
isSelected={isSelected}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
onOpenChange={onBranchDropdownOpenChange}
|
||||
onFilterChange={onBranchFilterChange}
|
||||
onSwitchBranch={onSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
||||
!worktree.hasWorktree && !isSelected && "opacity-70"
|
||||
)}
|
||||
onClick={() => onSelectWorktree(worktree)}
|
||||
disabled={isActivating}
|
||||
title={
|
||||
worktree.hasWorktree
|
||||
? "Click to switch to this worktree's branch"
|
||||
: "Click to switch to this branch"
|
||||
}
|
||||
>
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{isActivating && !isRunning && (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
)}
|
||||
{worktree.branch}
|
||||
{worktree.hasChanges && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
{worktree.changedFilesCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isDevServerRunning && (
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0 rounded-none border-r-0",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
||||
"text-green-500"
|
||||
)}
|
||||
onClick={() => onOpenDevServerUrl(worktree)}
|
||||
title={`Open dev server (port ${devServerInfo?.port})`}
|
||||
>
|
||||
<Globe className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<WorktreeActionsDropdown
|
||||
worktree={worktree}
|
||||
isSelected={isSelected}
|
||||
defaultEditorName={defaultEditorName}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
isDevServerRunning={isDevServerRunning}
|
||||
devServerInfo={devServerInfo}
|
||||
onOpenChange={onActionsDropdownOpenChange}
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
onOpenInEditor={onOpenInEditor}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={onStartDevServer}
|
||||
onStopDevServer={onStopDevServer}
|
||||
onOpenDevServerUrl={onOpenDevServerUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { useWorktrees } from "./use-worktrees";
|
||||
export { useDevServers } from "./use-dev-servers";
|
||||
export { useBranches } from "./use-branches";
|
||||
export { useWorktreeActions } from "./use-worktree-actions";
|
||||
export { useDefaultEditor } from "./use-default-editor";
|
||||
export { useRunningFeatures } from "./use-running-features";
|
||||
@@ -0,0 +1,53 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import type { BranchInfo } from "../types";
|
||||
|
||||
export function useBranches() {
|
||||
const [branches, setBranches] = useState<BranchInfo[]>([]);
|
||||
const [aheadCount, setAheadCount] = useState(0);
|
||||
const [behindCount, setBehindCount] = useState(0);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [branchFilter, setBranchFilter] = useState("");
|
||||
|
||||
const fetchBranches = useCallback(async (worktreePath: string) => {
|
||||
setIsLoadingBranches(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listBranches) {
|
||||
console.warn("List branches API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.listBranches(worktreePath);
|
||||
if (result.success && result.result) {
|
||||
setBranches(result.result.branches);
|
||||
setAheadCount(result.result.aheadCount || 0);
|
||||
setBehindCount(result.result.behindCount || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch branches:", error);
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetBranchFilter = useCallback(() => {
|
||||
setBranchFilter("");
|
||||
}, []);
|
||||
|
||||
const filteredBranches = branches.filter((b) =>
|
||||
b.name.toLowerCase().includes(branchFilter.toLowerCase())
|
||||
);
|
||||
|
||||
return {
|
||||
branches,
|
||||
filteredBranches,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
isLoadingBranches,
|
||||
branchFilter,
|
||||
setBranchFilter,
|
||||
resetBranchFilter,
|
||||
fetchBranches,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
|
||||
export function useDefaultEditor() {
|
||||
const [defaultEditorName, setDefaultEditorName] = useState<string>("Editor");
|
||||
|
||||
const fetchDefaultEditor = useCallback(async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.getDefaultEditor) {
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.getDefaultEditor();
|
||||
if (result.success && result.result?.editorName) {
|
||||
setDefaultEditorName(result.result.editorName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch default editor:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDefaultEditor();
|
||||
}, [fetchDefaultEditor]);
|
||||
|
||||
return {
|
||||
defaultEditorName,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { normalizePath } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import type { DevServerInfo, WorktreeInfo } from "../types";
|
||||
|
||||
interface UseDevServersOptions {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
|
||||
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
const fetchDevServers = useCallback(async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listDevServers) {
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.listDevServers();
|
||||
if (result.success && result.result?.servers) {
|
||||
const serversMap = new Map<string, DevServerInfo>();
|
||||
for (const server of result.result.servers) {
|
||||
serversMap.set(server.worktreePath, server);
|
||||
}
|
||||
setRunningDevServers(serversMap);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch dev servers:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevServers();
|
||||
}, [fetchDevServers]);
|
||||
|
||||
const getWorktreeKey = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
const path = worktree.isMain ? projectPath : worktree.path;
|
||||
return path ? normalizePath(path) : path;
|
||||
},
|
||||
[projectPath]
|
||||
);
|
||||
|
||||
const handleStartDevServer = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
if (isStartingDevServer) return;
|
||||
setIsStartingDevServer(true);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.startDevServer) {
|
||||
toast.error("Start dev server API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPath = worktree.isMain ? projectPath : worktree.path;
|
||||
const result = await api.worktree.startDevServer(projectPath, targetPath);
|
||||
|
||||
if (result.success && result.result) {
|
||||
setRunningDevServers((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(normalizePath(targetPath), {
|
||||
worktreePath: result.result!.worktreePath,
|
||||
port: result.result!.port,
|
||||
url: result.result!.url,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
toast.success(`Dev server started on port ${result.result.port}`);
|
||||
} else {
|
||||
toast.error(result.error || "Failed to start dev server");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Start dev server failed:", error);
|
||||
toast.error("Failed to start dev server");
|
||||
} finally {
|
||||
setIsStartingDevServer(false);
|
||||
}
|
||||
},
|
||||
[isStartingDevServer, projectPath]
|
||||
);
|
||||
|
||||
const handleStopDevServer = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.stopDevServer) {
|
||||
toast.error("Stop dev server API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPath = worktree.isMain ? projectPath : worktree.path;
|
||||
const result = await api.worktree.stopDevServer(targetPath);
|
||||
|
||||
if (result.success) {
|
||||
setRunningDevServers((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(normalizePath(targetPath));
|
||||
return next;
|
||||
});
|
||||
toast.success(result.result?.message || "Dev server stopped");
|
||||
} else {
|
||||
toast.error(result.error || "Failed to stop dev server");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Stop dev server failed:", error);
|
||||
toast.error("Failed to stop dev server");
|
||||
}
|
||||
},
|
||||
[projectPath]
|
||||
);
|
||||
|
||||
const handleOpenDevServerUrl = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
const targetPath = worktree.isMain ? projectPath : worktree.path;
|
||||
const serverInfo = runningDevServers.get(targetPath);
|
||||
if (serverInfo) {
|
||||
window.open(serverInfo.url, "_blank");
|
||||
}
|
||||
},
|
||||
[projectPath, runningDevServers]
|
||||
);
|
||||
|
||||
const isDevServerRunning = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
return runningDevServers.has(getWorktreeKey(worktree));
|
||||
},
|
||||
[runningDevServers, getWorktreeKey]
|
||||
);
|
||||
|
||||
const getDevServerInfo = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
return runningDevServers.get(getWorktreeKey(worktree));
|
||||
},
|
||||
[runningDevServers, getWorktreeKey]
|
||||
);
|
||||
|
||||
return {
|
||||
isStartingDevServer,
|
||||
runningDevServers,
|
||||
getWorktreeKey,
|
||||
isDevServerRunning,
|
||||
getDevServerInfo,
|
||||
handleStartDevServer,
|
||||
handleStopDevServer,
|
||||
handleOpenDevServerUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { pathsEqual } from "@/lib/utils";
|
||||
import type { WorktreeInfo, FeatureInfo } from "../types";
|
||||
|
||||
interface UseRunningFeaturesOptions {
|
||||
projectPath: string;
|
||||
runningFeatureIds: string[];
|
||||
features: FeatureInfo[];
|
||||
getWorktreeKey: (worktree: WorktreeInfo) => string;
|
||||
}
|
||||
|
||||
export function useRunningFeatures({
|
||||
projectPath,
|
||||
runningFeatureIds,
|
||||
features,
|
||||
getWorktreeKey,
|
||||
}: UseRunningFeaturesOptions) {
|
||||
const hasRunningFeatures = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
if (runningFeatureIds.length === 0) return false;
|
||||
|
||||
const worktreeKey = getWorktreeKey(worktree);
|
||||
|
||||
return runningFeatureIds.some((featureId) => {
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
if (!feature) return false;
|
||||
|
||||
if (feature.worktreePath) {
|
||||
if (worktree.isMain) {
|
||||
return pathsEqual(feature.worktreePath, projectPath);
|
||||
}
|
||||
return pathsEqual(feature.worktreePath, worktreeKey);
|
||||
}
|
||||
|
||||
if (feature.branchName) {
|
||||
return worktree.branch === feature.branchName;
|
||||
}
|
||||
|
||||
return worktree.isMain;
|
||||
});
|
||||
},
|
||||
[runningFeatureIds, features, projectPath, getWorktreeKey]
|
||||
);
|
||||
|
||||
return {
|
||||
hasRunningFeatures,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { toast } from "sonner";
|
||||
import type { WorktreeInfo } from "../types";
|
||||
|
||||
interface UseWorktreeActionsOptions {
|
||||
fetchWorktrees: () => Promise<void>;
|
||||
fetchBranches: (worktreePath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useWorktreeActions({
|
||||
fetchWorktrees,
|
||||
fetchBranches,
|
||||
}: UseWorktreeActionsOptions) {
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
const [isSwitching, setIsSwitching] = useState(false);
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
|
||||
const handleSwitchBranch = useCallback(
|
||||
async (worktree: WorktreeInfo, branchName: string) => {
|
||||
if (isSwitching || branchName === worktree.branch) return;
|
||||
setIsSwitching(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.switchBranch) {
|
||||
toast.error("Switch branch API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.switchBranch(worktree.path, branchName);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
fetchWorktrees();
|
||||
} else {
|
||||
toast.error(result.error || "Failed to switch branch");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Switch branch failed:", error);
|
||||
toast.error("Failed to switch branch");
|
||||
} finally {
|
||||
setIsSwitching(false);
|
||||
}
|
||||
},
|
||||
[isSwitching, fetchWorktrees]
|
||||
);
|
||||
|
||||
const handlePull = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
if (isPulling) return;
|
||||
setIsPulling(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.pull) {
|
||||
toast.error("Pull API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.pull(worktree.path);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
fetchWorktrees();
|
||||
} else {
|
||||
toast.error(result.error || "Failed to pull latest changes");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Pull failed:", error);
|
||||
toast.error("Failed to pull latest changes");
|
||||
} finally {
|
||||
setIsPulling(false);
|
||||
}
|
||||
},
|
||||
[isPulling, fetchWorktrees]
|
||||
);
|
||||
|
||||
const handlePush = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
if (isPushing) return;
|
||||
setIsPushing(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.push) {
|
||||
toast.error("Push API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.push(worktree.path);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
fetchBranches(worktree.path);
|
||||
fetchWorktrees();
|
||||
} else {
|
||||
toast.error(result.error || "Failed to push changes");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Push failed:", error);
|
||||
toast.error("Failed to push changes");
|
||||
} finally {
|
||||
setIsPushing(false);
|
||||
}
|
||||
},
|
||||
[isPushing, fetchBranches, fetchWorktrees]
|
||||
);
|
||||
|
||||
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.openInEditor) {
|
||||
console.warn("Open in editor API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.openInEditor(worktree.path);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
} else if (result.error) {
|
||||
toast.error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Open in editor failed:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isPulling,
|
||||
isPushing,
|
||||
isSwitching,
|
||||
isActivating,
|
||||
setIsActivating,
|
||||
handleSwitchBranch,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handleOpenInEditor,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { pathsEqual } from "@/lib/utils";
|
||||
import type { WorktreeInfo } from "../types";
|
||||
|
||||
interface UseWorktreesOptions {
|
||||
projectPath: string;
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOptions) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
|
||||
|
||||
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
|
||||
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
|
||||
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
|
||||
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
|
||||
|
||||
const fetchWorktrees = useCallback(async () => {
|
||||
if (!projectPath) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listAll) {
|
||||
console.warn("Worktree API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.listAll(projectPath, true);
|
||||
if (result.success && result.worktrees) {
|
||||
setWorktrees(result.worktrees);
|
||||
setWorktreesInStore(projectPath, result.worktrees);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch worktrees:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectPath, setWorktreesInStore]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorktrees();
|
||||
}, [fetchWorktrees]);
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshTrigger > 0) {
|
||||
fetchWorktrees();
|
||||
}
|
||||
}, [refreshTrigger, fetchWorktrees]);
|
||||
|
||||
useEffect(() => {
|
||||
if (worktrees.length > 0) {
|
||||
const currentPath = currentWorktree?.path;
|
||||
const currentWorktreeExists = currentPath === null
|
||||
? true
|
||||
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
|
||||
|
||||
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
|
||||
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||
const mainBranch = mainWorktree?.branch || "main";
|
||||
setCurrentWorktree(projectPath, null, mainBranch);
|
||||
}
|
||||
}
|
||||
}, [worktrees, currentWorktree, projectPath, setCurrentWorktree]);
|
||||
|
||||
const handleSelectWorktree = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
setCurrentWorktree(
|
||||
projectPath,
|
||||
worktree.isMain ? null : worktree.path,
|
||||
worktree.branch
|
||||
);
|
||||
},
|
||||
[projectPath, setCurrentWorktree]
|
||||
);
|
||||
|
||||
const currentWorktreePath = currentWorktree?.path ?? null;
|
||||
const selectedWorktree = currentWorktreePath
|
||||
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
|
||||
: worktrees.find((w) => w.isMain);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
worktrees,
|
||||
currentWorktree,
|
||||
currentWorktreePath,
|
||||
selectedWorktree,
|
||||
useWorktreesEnabled,
|
||||
fetchWorktrees,
|
||||
handleSelectWorktree,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { WorktreePanel } from "./worktree-panel";
|
||||
export type {
|
||||
WorktreeInfo,
|
||||
BranchInfo,
|
||||
DevServerInfo,
|
||||
FeatureInfo,
|
||||
WorktreePanelProps,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
isCurrent: boolean;
|
||||
hasWorktree: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
export interface BranchInfo {
|
||||
name: string;
|
||||
isCurrent: boolean;
|
||||
isRemote: boolean;
|
||||
}
|
||||
|
||||
export interface DevServerInfo {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface FeatureInfo {
|
||||
id: string;
|
||||
worktreePath?: string;
|
||||
branchName?: string;
|
||||
}
|
||||
|
||||
export interface WorktreePanelProps {
|
||||
projectPath: string;
|
||||
onCreateWorktree: () => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
runningFeatureIds?: string[];
|
||||
features?: FeatureInfo[];
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GitBranch, Plus, RefreshCw } from "lucide-react";
|
||||
import { cn, pathsEqual } from "@/lib/utils";
|
||||
import type { WorktreePanelProps, WorktreeInfo } from "./types";
|
||||
import {
|
||||
useWorktrees,
|
||||
useDevServers,
|
||||
useBranches,
|
||||
useWorktreeActions,
|
||||
useDefaultEditor,
|
||||
useRunningFeatures,
|
||||
} from "./hooks";
|
||||
import { WorktreeTab } from "./components";
|
||||
|
||||
export function WorktreePanel({
|
||||
projectPath,
|
||||
onCreateWorktree,
|
||||
onDeleteWorktree,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onCreateBranch,
|
||||
runningFeatureIds = [],
|
||||
features = [],
|
||||
refreshTrigger = 0,
|
||||
}: WorktreePanelProps) {
|
||||
const {
|
||||
isLoading,
|
||||
worktrees,
|
||||
currentWorktree,
|
||||
currentWorktreePath,
|
||||
useWorktreesEnabled,
|
||||
fetchWorktrees,
|
||||
handleSelectWorktree,
|
||||
} = useWorktrees({ projectPath, refreshTrigger });
|
||||
|
||||
const {
|
||||
isStartingDevServer,
|
||||
getWorktreeKey,
|
||||
isDevServerRunning,
|
||||
getDevServerInfo,
|
||||
handleStartDevServer,
|
||||
handleStopDevServer,
|
||||
handleOpenDevServerUrl,
|
||||
} = useDevServers({ projectPath });
|
||||
|
||||
const {
|
||||
branches,
|
||||
filteredBranches,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
isLoadingBranches,
|
||||
branchFilter,
|
||||
setBranchFilter,
|
||||
resetBranchFilter,
|
||||
fetchBranches,
|
||||
} = useBranches();
|
||||
|
||||
const {
|
||||
isPulling,
|
||||
isPushing,
|
||||
isSwitching,
|
||||
isActivating,
|
||||
handleSwitchBranch,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handleOpenInEditor,
|
||||
} = useWorktreeActions({
|
||||
fetchWorktrees,
|
||||
fetchBranches,
|
||||
});
|
||||
|
||||
const { defaultEditorName } = useDefaultEditor();
|
||||
|
||||
const { hasRunningFeatures } = useRunningFeatures({
|
||||
projectPath,
|
||||
runningFeatureIds,
|
||||
features,
|
||||
getWorktreeKey,
|
||||
});
|
||||
|
||||
const isWorktreeSelected = (worktree: WorktreeInfo) => {
|
||||
return worktree.isMain
|
||||
? currentWorktree === null ||
|
||||
currentWorktree === undefined ||
|
||||
currentWorktree.path === null
|
||||
: pathsEqual(worktree.path, currentWorktreePath);
|
||||
};
|
||||
|
||||
const handleBranchDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
|
||||
if (open) {
|
||||
fetchBranches(worktree.path);
|
||||
resetBranchFilter();
|
||||
}
|
||||
};
|
||||
|
||||
const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
|
||||
if (open) {
|
||||
fetchBranches(worktree.path);
|
||||
}
|
||||
};
|
||||
|
||||
if (!useWorktreesEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
|
||||
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{worktrees.map((worktree) => (
|
||||
<WorktreeTab
|
||||
key={worktree.path}
|
||||
worktree={worktree}
|
||||
isSelected={isWorktreeSelected(worktree)}
|
||||
isRunning={hasRunningFeatures(worktree)}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(worktree)}
|
||||
devServerInfo={getDevServerInfo(worktree)}
|
||||
defaultEditorName={defaultEditorName}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCreateWorktree}
|
||||
title="Create new worktree"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={fetchWorktrees}
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,8 @@ export function SettingsView() {
|
||||
setProjectTheme,
|
||||
defaultSkipTests,
|
||||
setDefaultSkipTests,
|
||||
enableDependencyBlocking,
|
||||
setEnableDependencyBlocking,
|
||||
useWorktrees,
|
||||
setUseWorktrees,
|
||||
showProfilesOnly,
|
||||
@@ -117,9 +119,11 @@ export function SettingsView() {
|
||||
<FeatureDefaultsSection
|
||||
showProfilesOnly={showProfilesOnly}
|
||||
defaultSkipTests={defaultSkipTests}
|
||||
enableDependencyBlocking={enableDependencyBlocking}
|
||||
useWorktrees={useWorktrees}
|
||||
onShowProfilesOnlyChange={setShowProfilesOnly}
|
||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||
onUseWorktreesChange={setUseWorktrees}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { FlaskConical, Settings2, TestTube, GitBranch } from "lucide-react";
|
||||
import { FlaskConical, Settings2, TestTube, GitBranch, AlertCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FeatureDefaultsSectionProps {
|
||||
showProfilesOnly: boolean;
|
||||
defaultSkipTests: boolean;
|
||||
enableDependencyBlocking: boolean;
|
||||
useWorktrees: boolean;
|
||||
onShowProfilesOnlyChange: (value: boolean) => void;
|
||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||
onEnableDependencyBlockingChange: (value: boolean) => void;
|
||||
onUseWorktreesChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function FeatureDefaultsSection({
|
||||
showProfilesOnly,
|
||||
defaultSkipTests,
|
||||
enableDependencyBlocking,
|
||||
useWorktrees,
|
||||
onShowProfilesOnlyChange,
|
||||
onDefaultSkipTestsChange,
|
||||
onEnableDependencyBlockingChange,
|
||||
onUseWorktreesChange,
|
||||
}: FeatureDefaultsSectionProps) {
|
||||
return (
|
||||
@@ -102,6 +106,36 @@ export function FeatureDefaultsSection({
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Dependency Blocking Setting */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="enable-dependency-blocking"
|
||||
checked={enableDependencyBlocking}
|
||||
onCheckedChange={(checked) =>
|
||||
onEnableDependencyBlockingChange(checked === true)
|
||||
}
|
||||
className="mt-1"
|
||||
data-testid="enable-dependency-blocking-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="enable-dependency-blocking"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 text-brand-500" />
|
||||
Enable Dependency Blocking
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
When enabled, features with incomplete dependencies will show blocked badges
|
||||
and warnings. Auto mode and backlog ordering always respect dependencies
|
||||
regardless of this setting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Worktree Isolation Setting */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
|
||||
@@ -30,7 +30,10 @@ export type Theme =
|
||||
| "catppuccin"
|
||||
| "onedark"
|
||||
| "synthwave"
|
||||
| "red";
|
||||
| "red"
|
||||
| "cream"
|
||||
| "sunset"
|
||||
| "gray";
|
||||
|
||||
export type KanbanDetailLevel = "minimal" | "standard" | "detailed";
|
||||
|
||||
|
||||
@@ -356,6 +356,81 @@ const redTheme: TerminalTheme = {
|
||||
brightWhite: "#d0c0c0",
|
||||
};
|
||||
|
||||
// Cream theme - Warm, soft, easy on the eyes
|
||||
const creamTheme: TerminalTheme = {
|
||||
background: "#f5f3ee",
|
||||
foreground: "#5a4a3a",
|
||||
cursor: "#9d6b53",
|
||||
cursorAccent: "#f5f3ee",
|
||||
selectionBackground: "#d4c4b0",
|
||||
black: "#5a4a3a",
|
||||
red: "#c85a4f",
|
||||
green: "#7a9a6a",
|
||||
yellow: "#c9a554",
|
||||
blue: "#6b8aaa",
|
||||
magenta: "#a66a8a",
|
||||
cyan: "#5a9a8a",
|
||||
white: "#b0a090",
|
||||
brightBlack: "#8a7a6a",
|
||||
brightRed: "#e07060",
|
||||
brightGreen: "#90b080",
|
||||
brightYellow: "#e0bb70",
|
||||
brightBlue: "#80a0c0",
|
||||
brightMagenta: "#c080a0",
|
||||
brightCyan: "#70b0a0",
|
||||
brightWhite: "#d0c0b0",
|
||||
};
|
||||
|
||||
// Sunset theme - Mellow oranges and soft pastels
|
||||
const sunsetTheme: TerminalTheme = {
|
||||
background: "#1e1a24",
|
||||
foreground: "#f2e8dd",
|
||||
cursor: "#dd8855",
|
||||
cursorAccent: "#1e1a24",
|
||||
selectionBackground: "#3a2a40",
|
||||
black: "#1e1a24",
|
||||
red: "#dd6655",
|
||||
green: "#88bb77",
|
||||
yellow: "#ddaa66",
|
||||
blue: "#6699cc",
|
||||
magenta: "#cc7799",
|
||||
cyan: "#66ccaa",
|
||||
white: "#e8d8c8",
|
||||
brightBlack: "#4a3a50",
|
||||
brightRed: "#ee8866",
|
||||
brightGreen: "#99cc88",
|
||||
brightYellow: "#eebb77",
|
||||
brightBlue: "#88aadd",
|
||||
brightMagenta: "#dd88aa",
|
||||
brightCyan: "#88ddbb",
|
||||
brightWhite: "#f5e8dd",
|
||||
};
|
||||
|
||||
// Gray theme - Modern, minimal gray scheme inspired by Cursor
|
||||
const grayTheme: TerminalTheme = {
|
||||
background: "#2a2d32",
|
||||
foreground: "#d0d0d5",
|
||||
cursor: "#8fa0c0",
|
||||
cursorAccent: "#2a2d32",
|
||||
selectionBackground: "#3a3f48",
|
||||
black: "#2a2d32",
|
||||
red: "#d87070",
|
||||
green: "#78b088",
|
||||
yellow: "#d0b060",
|
||||
blue: "#7090c0",
|
||||
magenta: "#a880b0",
|
||||
cyan: "#60a0b0",
|
||||
white: "#b0b0b8",
|
||||
brightBlack: "#606068",
|
||||
brightRed: "#e88888",
|
||||
brightGreen: "#90c8a0",
|
||||
brightYellow: "#e0c878",
|
||||
brightBlue: "#90b0d8",
|
||||
brightMagenta: "#c098c8",
|
||||
brightCyan: "#80b8c8",
|
||||
brightWhite: "#e0e0e8",
|
||||
};
|
||||
|
||||
// Theme mapping
|
||||
const terminalThemes: Record<ThemeMode, TerminalTheme> = {
|
||||
light: lightTheme,
|
||||
@@ -372,6 +447,9 @@ const terminalThemes: Record<ThemeMode, TerminalTheme> = {
|
||||
onedark: onedarkTheme,
|
||||
synthwave: synthwaveTheme,
|
||||
red: redTheme,
|
||||
cream: creamTheme,
|
||||
sunset: sunsetTheme,
|
||||
gray: grayTheme,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
type LucideIcon,
|
||||
Atom,
|
||||
Cat,
|
||||
CloudSun,
|
||||
Coffee,
|
||||
Eclipse,
|
||||
Flame,
|
||||
Ghost,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
Radio,
|
||||
Snowflake,
|
||||
Sparkles,
|
||||
Square,
|
||||
Sun,
|
||||
Terminal,
|
||||
Trees,
|
||||
@@ -92,4 +95,22 @@ export const themeOptions: ReadonlyArray<ThemeOption> = [
|
||||
Icon: Heart,
|
||||
testId: "red-mode-button",
|
||||
},
|
||||
{
|
||||
value: "cream",
|
||||
label: "Cream",
|
||||
Icon: Coffee,
|
||||
testId: "cream-mode-button",
|
||||
},
|
||||
{
|
||||
value: "sunset",
|
||||
label: "Sunset",
|
||||
Icon: CloudSun,
|
||||
testId: "sunset-mode-button",
|
||||
},
|
||||
{
|
||||
value: "gray",
|
||||
label: "Gray",
|
||||
Icon: Square,
|
||||
testId: "gray-mode-button",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -130,9 +130,16 @@ function getCurrentPhase(content: string): "planning" | "action" | "verification
|
||||
|
||||
/**
|
||||
* Extracts a summary from completed feature context
|
||||
* Looks for content between <summary> and </summary> tags
|
||||
*/
|
||||
function extractSummary(content: string): string | undefined {
|
||||
// Look for summary sections - capture everything including subsections (###)
|
||||
// Look for <summary> tags - capture everything between opening and closing tags
|
||||
const summaryTagMatch = content.match(/<summary>([\s\S]*?)<\/summary>/i);
|
||||
if (summaryTagMatch) {
|
||||
return summaryTagMatch[1].trim();
|
||||
}
|
||||
|
||||
// Fallback: Look for summary sections - capture everything including subsections (###)
|
||||
// Stop at same-level ## sections (but not ###), or tool markers, or end
|
||||
const summaryMatch = content.match(/## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i);
|
||||
if (summaryMatch) {
|
||||
|
||||
221
apps/ui/src/lib/dependency-resolver.ts
Normal file
221
apps/ui/src/lib/dependency-resolver.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Dependency Resolution Utility
|
||||
*
|
||||
* Provides topological sorting and dependency analysis for features.
|
||||
* Uses a modified Kahn's algorithm that respects both dependencies and priorities.
|
||||
*/
|
||||
|
||||
import type { Feature } from "@/store/app-store";
|
||||
|
||||
export interface DependencyResolutionResult {
|
||||
orderedFeatures: Feature[]; // Features in dependency-aware order
|
||||
circularDependencies: string[][]; // Groups of IDs forming cycles
|
||||
missingDependencies: Map<string, string[]>; // featureId -> missing dep IDs
|
||||
blockedFeatures: Map<string, string[]>; // featureId -> blocking dep IDs (incomplete dependencies)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves feature dependencies using topological sort with priority-aware ordering.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Build dependency graph and detect missing/blocked dependencies
|
||||
* 2. Apply Kahn's algorithm for topological sort
|
||||
* 3. Within same dependency level, sort by priority (1=high, 2=medium, 3=low)
|
||||
* 4. Detect circular dependencies for features that can't be ordered
|
||||
*
|
||||
* @param features - Array of features to order
|
||||
* @returns Resolution result with ordered features and dependency metadata
|
||||
*/
|
||||
export function resolveDependencies(features: Feature[]): DependencyResolutionResult {
|
||||
const featureMap = new Map<string, Feature>(features.map(f => [f.id, f]));
|
||||
const inDegree = new Map<string, number>();
|
||||
const adjacencyList = new Map<string, string[]>(); // dependencyId -> [dependentIds]
|
||||
const missingDependencies = new Map<string, string[]>();
|
||||
const blockedFeatures = new Map<string, string[]>();
|
||||
|
||||
// Initialize graph structures
|
||||
for (const feature of features) {
|
||||
inDegree.set(feature.id, 0);
|
||||
adjacencyList.set(feature.id, []);
|
||||
}
|
||||
|
||||
// Build dependency graph and detect missing/blocked dependencies
|
||||
for (const feature of features) {
|
||||
const deps = feature.dependencies || [];
|
||||
for (const depId of deps) {
|
||||
if (!featureMap.has(depId)) {
|
||||
// Missing dependency - track it
|
||||
if (!missingDependencies.has(feature.id)) {
|
||||
missingDependencies.set(feature.id, []);
|
||||
}
|
||||
missingDependencies.get(feature.id)!.push(depId);
|
||||
} else {
|
||||
// Valid dependency - add edge to graph
|
||||
adjacencyList.get(depId)!.push(feature.id);
|
||||
inDegree.set(feature.id, (inDegree.get(feature.id) || 0) + 1);
|
||||
|
||||
// Check if dependency is incomplete (blocking)
|
||||
const depFeature = featureMap.get(depId)!;
|
||||
if (depFeature.status !== 'completed' && depFeature.status !== 'verified') {
|
||||
if (!blockedFeatures.has(feature.id)) {
|
||||
blockedFeatures.set(feature.id, []);
|
||||
}
|
||||
blockedFeatures.get(feature.id)!.push(depId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kahn's algorithm with priority-aware selection
|
||||
const queue: Feature[] = [];
|
||||
const orderedFeatures: Feature[] = [];
|
||||
|
||||
// Helper to sort features by priority (lower number = higher priority)
|
||||
const sortByPriority = (a: Feature, b: Feature) =>
|
||||
(a.priority ?? 2) - (b.priority ?? 2);
|
||||
|
||||
// Start with features that have no dependencies (in-degree 0)
|
||||
for (const [id, degree] of inDegree) {
|
||||
if (degree === 0) {
|
||||
queue.push(featureMap.get(id)!);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort initial queue by priority
|
||||
queue.sort(sortByPriority);
|
||||
|
||||
// Process features in topological order
|
||||
while (queue.length > 0) {
|
||||
// Take highest priority feature from queue
|
||||
const current = queue.shift()!;
|
||||
orderedFeatures.push(current);
|
||||
|
||||
// Process features that depend on this one
|
||||
for (const dependentId of adjacencyList.get(current.id) || []) {
|
||||
const currentDegree = inDegree.get(dependentId);
|
||||
if (currentDegree === undefined) {
|
||||
throw new Error(`In-degree not initialized for feature ${dependentId}`);
|
||||
}
|
||||
const newDegree = currentDegree - 1;
|
||||
inDegree.set(dependentId, newDegree);
|
||||
|
||||
if (newDegree === 0) {
|
||||
queue.push(featureMap.get(dependentId)!);
|
||||
// Re-sort queue to maintain priority order
|
||||
queue.sort(sortByPriority);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect circular dependencies (features not in output = part of cycle)
|
||||
const circularDependencies: string[][] = [];
|
||||
const processedIds = new Set(orderedFeatures.map(f => f.id));
|
||||
|
||||
if (orderedFeatures.length < features.length) {
|
||||
// Find cycles using DFS
|
||||
const remaining = features.filter(f => !processedIds.has(f.id));
|
||||
const cycles = detectCycles(remaining, featureMap);
|
||||
circularDependencies.push(...cycles);
|
||||
|
||||
// Add remaining features at end (part of cycles)
|
||||
orderedFeatures.push(...remaining);
|
||||
}
|
||||
|
||||
return {
|
||||
orderedFeatures,
|
||||
circularDependencies,
|
||||
missingDependencies,
|
||||
blockedFeatures
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects circular dependencies using depth-first search
|
||||
*
|
||||
* @param features - Features that couldn't be topologically sorted (potential cycles)
|
||||
* @param featureMap - Map of all features by ID
|
||||
* @returns Array of cycles, where each cycle is an array of feature IDs
|
||||
*/
|
||||
function detectCycles(
|
||||
features: Feature[],
|
||||
featureMap: Map<string, Feature>
|
||||
): string[][] {
|
||||
const cycles: string[][] = [];
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
const currentPath: string[] = [];
|
||||
|
||||
function dfs(featureId: string): boolean {
|
||||
visited.add(featureId);
|
||||
recursionStack.add(featureId);
|
||||
currentPath.push(featureId);
|
||||
|
||||
const feature = featureMap.get(featureId);
|
||||
if (feature) {
|
||||
for (const depId of feature.dependencies || []) {
|
||||
if (!visited.has(depId)) {
|
||||
if (dfs(depId)) return true;
|
||||
} else if (recursionStack.has(depId)) {
|
||||
// Found cycle - extract it
|
||||
const cycleStart = currentPath.indexOf(depId);
|
||||
cycles.push(currentPath.slice(cycleStart));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentPath.pop();
|
||||
recursionStack.delete(featureId);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const feature of features) {
|
||||
if (!visited.has(feature.id)) {
|
||||
dfs(feature.id);
|
||||
}
|
||||
}
|
||||
|
||||
return cycles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a feature's dependencies are satisfied (all complete or verified)
|
||||
*
|
||||
* @param feature - Feature to check
|
||||
* @param allFeatures - All features in the project
|
||||
* @returns true if all dependencies are satisfied, false otherwise
|
||||
*/
|
||||
export function areDependenciesSatisfied(
|
||||
feature: Feature,
|
||||
allFeatures: Feature[]
|
||||
): boolean {
|
||||
if (!feature.dependencies || feature.dependencies.length === 0) {
|
||||
return true; // No dependencies = always ready
|
||||
}
|
||||
|
||||
return feature.dependencies.every(depId => {
|
||||
const dep = allFeatures.find(f => f.id === depId);
|
||||
return dep && (dep.status === 'completed' || dep.status === 'verified');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the blocking dependencies for a feature (dependencies that are incomplete)
|
||||
*
|
||||
* @param feature - Feature to check
|
||||
* @param allFeatures - All features in the project
|
||||
* @returns Array of feature IDs that are blocking this feature
|
||||
*/
|
||||
export function getBlockingDependencies(
|
||||
feature: Feature,
|
||||
allFeatures: Feature[]
|
||||
): string[] {
|
||||
if (!feature.dependencies || feature.dependencies.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return feature.dependencies.filter(depId => {
|
||||
const dep = allFeatures.find(f => f.id === depId);
|
||||
return dep && dep.status !== 'completed' && dep.status !== 'verified';
|
||||
});
|
||||
}
|
||||
@@ -30,7 +30,10 @@ export type ThemeMode =
|
||||
| "catppuccin"
|
||||
| "onedark"
|
||||
| "synthwave"
|
||||
| "red";
|
||||
| "red"
|
||||
| "cream"
|
||||
| "sunset"
|
||||
| "gray";
|
||||
|
||||
export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed";
|
||||
|
||||
@@ -394,6 +397,7 @@ export interface AppState {
|
||||
|
||||
// Feature Default Settings
|
||||
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
||||
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
|
||||
|
||||
// Worktree Settings
|
||||
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: false)
|
||||
@@ -580,6 +584,7 @@ export interface AppActions {
|
||||
|
||||
// Feature Default Settings actions
|
||||
setDefaultSkipTests: (skip: boolean) => void;
|
||||
setEnableDependencyBlocking: (enabled: boolean) => void;
|
||||
|
||||
// Worktree Settings actions
|
||||
setUseWorktrees: (enabled: boolean) => void;
|
||||
@@ -750,6 +755,7 @@ const initialState: AppState = {
|
||||
maxConcurrency: 3, // Default to 3 concurrent agents
|
||||
kanbanCardDetailLevel: "standard", // Default to standard detail level
|
||||
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
||||
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
||||
useWorktrees: false, // Default to disabled (worktree feature is experimental)
|
||||
currentWorktreeByProject: {},
|
||||
worktreesByProject: {},
|
||||
@@ -1341,6 +1347,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
|
||||
// Feature Default Settings actions
|
||||
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
|
||||
setEnableDependencyBlocking: (enabled) => set({ enableDependencyBlocking: enabled }),
|
||||
|
||||
// Worktree Settings actions
|
||||
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
|
||||
@@ -2232,6 +2239,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
maxConcurrency: state.maxConcurrency,
|
||||
autoModeByProject: state.autoModeByProject,
|
||||
defaultSkipTests: state.defaultSkipTests,
|
||||
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||
useWorktrees: state.useWorktrees,
|
||||
currentWorktreeByProject: state.currentWorktreeByProject,
|
||||
worktreesByProject: state.worktreesByProject,
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
@custom-variant onedark (&:is(.onedark *));
|
||||
@custom-variant synthwave (&:is(.synthwave *));
|
||||
@custom-variant red (&:is(.red *));
|
||||
@custom-variant cream (&:is(.cream *));
|
||||
@custom-variant sunset (&:is(.sunset *));
|
||||
@custom-variant gray (&:is(.gray *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
@@ -1220,6 +1223,252 @@
|
||||
--running-indicator-text: oklch(0.6 0.23 25);
|
||||
}
|
||||
|
||||
.cream {
|
||||
/* Cream Theme - Warm, soft, easy on the eyes */
|
||||
--background: oklch(0.95 0.01 70); /* Warm cream background */
|
||||
--background-50: oklch(0.95 0.01 70 / 0.5);
|
||||
--background-80: oklch(0.95 0.01 70 / 0.8);
|
||||
|
||||
--foreground: oklch(0.25 0.02 60); /* Dark warm brown */
|
||||
--foreground-secondary: oklch(0.45 0.02 60); /* Medium brown */
|
||||
--foreground-muted: oklch(0.55 0.02 60); /* Light brown */
|
||||
|
||||
--card: oklch(0.98 0.005 70); /* Slightly lighter cream */
|
||||
--card-foreground: oklch(0.25 0.02 60);
|
||||
--popover: oklch(0.97 0.008 70);
|
||||
--popover-foreground: oklch(0.25 0.02 60);
|
||||
|
||||
--primary: oklch(0.5 0.12 45); /* Warm terracotta/rust */
|
||||
--primary-foreground: oklch(0.98 0.005 70);
|
||||
|
||||
--brand-400: oklch(0.55 0.12 45);
|
||||
--brand-500: oklch(0.5 0.12 45); /* Terracotta */
|
||||
--brand-600: oklch(0.45 0.13 45);
|
||||
|
||||
--secondary: oklch(0.88 0.02 70);
|
||||
--secondary-foreground: oklch(0.25 0.02 60);
|
||||
|
||||
--muted: oklch(0.9 0.015 70);
|
||||
--muted-foreground: oklch(0.45 0.02 60);
|
||||
|
||||
--accent: oklch(0.85 0.025 70);
|
||||
--accent-foreground: oklch(0.25 0.02 60);
|
||||
|
||||
--destructive: oklch(0.55 0.22 25); /* Warm red */
|
||||
|
||||
--border: oklch(0.85 0.015 70);
|
||||
--border-glass: oklch(0.5 0.12 45 / 0.2);
|
||||
|
||||
--input: oklch(0.98 0.005 70);
|
||||
--ring: oklch(0.5 0.12 45);
|
||||
|
||||
--chart-1: oklch(0.5 0.12 45); /* Terracotta */
|
||||
--chart-2: oklch(0.55 0.15 35); /* Burnt orange */
|
||||
--chart-3: oklch(0.6 0.12 100); /* Olive */
|
||||
--chart-4: oklch(0.5 0.15 20); /* Deep rust */
|
||||
--chart-5: oklch(0.65 0.1 80); /* Golden */
|
||||
|
||||
--sidebar: oklch(0.93 0.012 70);
|
||||
--sidebar-foreground: oklch(0.25 0.02 60);
|
||||
--sidebar-primary: oklch(0.5 0.12 45);
|
||||
--sidebar-primary-foreground: oklch(0.98 0.005 70);
|
||||
--sidebar-accent: oklch(0.88 0.02 70);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.02 60);
|
||||
--sidebar-border: oklch(0.85 0.015 70);
|
||||
--sidebar-ring: oklch(0.5 0.12 45);
|
||||
|
||||
/* Action button colors - Warm earth tones */
|
||||
--action-view: oklch(0.5 0.12 45); /* Terracotta */
|
||||
--action-view-hover: oklch(0.45 0.13 45);
|
||||
--action-followup: oklch(0.55 0.15 35); /* Burnt orange */
|
||||
--action-followup-hover: oklch(0.5 0.16 35);
|
||||
--action-commit: oklch(0.55 0.12 130); /* Sage green */
|
||||
--action-commit-hover: oklch(0.5 0.13 130);
|
||||
--action-verify: oklch(0.55 0.12 130); /* Sage green */
|
||||
--action-verify-hover: oklch(0.5 0.13 130);
|
||||
|
||||
/* Running indicator - Terracotta */
|
||||
--running-indicator: oklch(0.5 0.12 45);
|
||||
--running-indicator-text: oklch(0.55 0.12 45);
|
||||
|
||||
/* Status colors - Cream theme */
|
||||
--status-success: oklch(0.55 0.15 130);
|
||||
--status-success-bg: oklch(0.55 0.15 130 / 0.15);
|
||||
--status-warning: oklch(0.6 0.15 70);
|
||||
--status-warning-bg: oklch(0.6 0.15 70 / 0.15);
|
||||
--status-error: oklch(0.55 0.22 25);
|
||||
--status-error-bg: oklch(0.55 0.22 25 / 0.15);
|
||||
--status-info: oklch(0.5 0.15 230);
|
||||
--status-info-bg: oklch(0.5 0.15 230 / 0.15);
|
||||
--status-backlog: oklch(0.6 0.02 60);
|
||||
--status-in-progress: oklch(0.6 0.15 70);
|
||||
--status-waiting: oklch(0.58 0.13 50);
|
||||
}
|
||||
|
||||
.sunset {
|
||||
/* Sunset Theme - Mellow oranges and soft purples */
|
||||
--background: oklch(0.15 0.02 280); /* Deep twilight blue-purple */
|
||||
--background-50: oklch(0.15 0.02 280 / 0.5);
|
||||
--background-80: oklch(0.15 0.02 280 / 0.8);
|
||||
|
||||
--foreground: oklch(0.95 0.01 80); /* Warm white */
|
||||
--foreground-secondary: oklch(0.75 0.02 60);
|
||||
--foreground-muted: oklch(0.6 0.02 60);
|
||||
|
||||
--card: oklch(0.2 0.025 280);
|
||||
--card-foreground: oklch(0.95 0.01 80);
|
||||
--popover: oklch(0.18 0.02 280);
|
||||
--popover-foreground: oklch(0.95 0.01 80);
|
||||
|
||||
--primary: oklch(0.68 0.18 45); /* Mellow sunset orange */
|
||||
--primary-foreground: oklch(0.15 0.02 280);
|
||||
|
||||
--brand-400: oklch(0.72 0.17 45);
|
||||
--brand-500: oklch(0.68 0.18 45); /* Soft sunset orange */
|
||||
--brand-600: oklch(0.64 0.19 42);
|
||||
|
||||
--secondary: oklch(0.25 0.03 280);
|
||||
--secondary-foreground: oklch(0.95 0.01 80);
|
||||
|
||||
--muted: oklch(0.27 0.03 280);
|
||||
--muted-foreground: oklch(0.6 0.02 60);
|
||||
|
||||
--accent: oklch(0.35 0.04 310);
|
||||
--accent-foreground: oklch(0.95 0.01 80);
|
||||
|
||||
--destructive: oklch(0.6 0.2 25); /* Muted red */
|
||||
|
||||
--border: oklch(0.32 0.04 280);
|
||||
--border-glass: oklch(0.68 0.18 45 / 0.3);
|
||||
|
||||
--input: oklch(0.2 0.025 280);
|
||||
--ring: oklch(0.68 0.18 45);
|
||||
|
||||
--chart-1: oklch(0.68 0.18 45); /* Mellow orange */
|
||||
--chart-2: oklch(0.75 0.16 340); /* Soft pink sunset */
|
||||
--chart-3: oklch(0.78 0.18 70); /* Soft golden */
|
||||
--chart-4: oklch(0.66 0.19 42); /* Subtle coral */
|
||||
--chart-5: oklch(0.72 0.14 310); /* Pastel purple */
|
||||
|
||||
--sidebar: oklch(0.13 0.015 280);
|
||||
--sidebar-foreground: oklch(0.95 0.01 80);
|
||||
--sidebar-primary: oklch(0.68 0.18 45);
|
||||
--sidebar-primary-foreground: oklch(0.15 0.02 280);
|
||||
--sidebar-accent: oklch(0.25 0.03 280);
|
||||
--sidebar-accent-foreground: oklch(0.95 0.01 80);
|
||||
--sidebar-border: oklch(0.32 0.04 280);
|
||||
--sidebar-ring: oklch(0.68 0.18 45);
|
||||
|
||||
/* Action button colors - Mellow sunset palette */
|
||||
--action-view: oklch(0.68 0.18 45); /* Mellow orange */
|
||||
--action-view-hover: oklch(0.64 0.19 42);
|
||||
--action-followup: oklch(0.75 0.16 340); /* Soft pink */
|
||||
--action-followup-hover: oklch(0.7 0.17 340);
|
||||
--action-commit: oklch(0.65 0.16 140); /* Soft green */
|
||||
--action-commit-hover: oklch(0.6 0.17 140);
|
||||
--action-verify: oklch(0.65 0.16 140); /* Soft green */
|
||||
--action-verify-hover: oklch(0.6 0.17 140);
|
||||
|
||||
/* Running indicator - Mellow orange */
|
||||
--running-indicator: oklch(0.68 0.18 45);
|
||||
--running-indicator-text: oklch(0.72 0.17 45);
|
||||
|
||||
/* Status colors - Sunset theme */
|
||||
--status-success: oklch(0.65 0.16 140);
|
||||
--status-success-bg: oklch(0.65 0.16 140 / 0.2);
|
||||
--status-warning: oklch(0.78 0.18 70);
|
||||
--status-warning-bg: oklch(0.78 0.18 70 / 0.2);
|
||||
--status-error: oklch(0.65 0.2 25);
|
||||
--status-error-bg: oklch(0.65 0.2 25 / 0.2);
|
||||
--status-info: oklch(0.75 0.16 340);
|
||||
--status-info-bg: oklch(0.75 0.16 340 / 0.2);
|
||||
--status-backlog: oklch(0.65 0.02 280);
|
||||
--status-in-progress: oklch(0.78 0.18 70);
|
||||
--status-waiting: oklch(0.72 0.17 60);
|
||||
}
|
||||
|
||||
.gray {
|
||||
/* Gray Theme - Modern, minimal gray scheme inspired by Cursor */
|
||||
--background: oklch(0.2 0.005 250); /* Medium-dark neutral gray */
|
||||
--background-50: oklch(0.2 0.005 250 / 0.5);
|
||||
--background-80: oklch(0.2 0.005 250 / 0.8);
|
||||
|
||||
--foreground: oklch(0.9 0.005 250); /* Light gray */
|
||||
--foreground-secondary: oklch(0.65 0.005 250);
|
||||
--foreground-muted: oklch(0.5 0.005 250);
|
||||
|
||||
--card: oklch(0.24 0.005 250);
|
||||
--card-foreground: oklch(0.9 0.005 250);
|
||||
--popover: oklch(0.22 0.005 250);
|
||||
--popover-foreground: oklch(0.9 0.005 250);
|
||||
|
||||
--primary: oklch(0.6 0.08 250); /* Subtle blue-gray */
|
||||
--primary-foreground: oklch(0.95 0.005 250);
|
||||
|
||||
--brand-400: oklch(0.65 0.08 250);
|
||||
--brand-500: oklch(0.6 0.08 250); /* Blue-gray */
|
||||
--brand-600: oklch(0.55 0.09 250);
|
||||
|
||||
--secondary: oklch(0.28 0.005 250);
|
||||
--secondary-foreground: oklch(0.9 0.005 250);
|
||||
|
||||
--muted: oklch(0.3 0.005 250);
|
||||
--muted-foreground: oklch(0.6 0.005 250);
|
||||
|
||||
--accent: oklch(0.35 0.01 250);
|
||||
--accent-foreground: oklch(0.9 0.005 250);
|
||||
|
||||
--destructive: oklch(0.6 0.2 25); /* Muted red */
|
||||
|
||||
--border: oklch(0.32 0.005 250);
|
||||
--border-glass: oklch(0.6 0.08 250 / 0.2);
|
||||
|
||||
--input: oklch(0.24 0.005 250);
|
||||
--ring: oklch(0.6 0.08 250);
|
||||
|
||||
--chart-1: oklch(0.6 0.08 250); /* Blue-gray */
|
||||
--chart-2: oklch(0.65 0.1 210); /* Cyan */
|
||||
--chart-3: oklch(0.7 0.12 160); /* Teal */
|
||||
--chart-4: oklch(0.65 0.1 280); /* Purple */
|
||||
--chart-5: oklch(0.7 0.08 300); /* Violet */
|
||||
|
||||
--sidebar: oklch(0.18 0.005 250);
|
||||
--sidebar-foreground: oklch(0.9 0.005 250);
|
||||
--sidebar-primary: oklch(0.6 0.08 250);
|
||||
--sidebar-primary-foreground: oklch(0.95 0.005 250);
|
||||
--sidebar-accent: oklch(0.28 0.005 250);
|
||||
--sidebar-accent-foreground: oklch(0.9 0.005 250);
|
||||
--sidebar-border: oklch(0.32 0.005 250);
|
||||
--sidebar-ring: oklch(0.6 0.08 250);
|
||||
|
||||
/* Action button colors - Subtle modern colors */
|
||||
--action-view: oklch(0.6 0.08 250); /* Blue-gray */
|
||||
--action-view-hover: oklch(0.55 0.09 250);
|
||||
--action-followup: oklch(0.65 0.1 210); /* Cyan */
|
||||
--action-followup-hover: oklch(0.6 0.11 210);
|
||||
--action-commit: oklch(0.65 0.12 150); /* Teal-green */
|
||||
--action-commit-hover: oklch(0.6 0.13 150);
|
||||
--action-verify: oklch(0.65 0.12 150); /* Teal-green */
|
||||
--action-verify-hover: oklch(0.6 0.13 150);
|
||||
|
||||
/* Running indicator - Blue-gray */
|
||||
--running-indicator: oklch(0.6 0.08 250);
|
||||
--running-indicator-text: oklch(0.65 0.08 250);
|
||||
|
||||
/* Status colors - Gray theme */
|
||||
--status-success: oklch(0.65 0.12 150);
|
||||
--status-success-bg: oklch(0.65 0.12 150 / 0.2);
|
||||
--status-warning: oklch(0.7 0.15 70);
|
||||
--status-warning-bg: oklch(0.7 0.15 70 / 0.2);
|
||||
--status-error: oklch(0.6 0.2 25);
|
||||
--status-error-bg: oklch(0.6 0.2 25 / 0.2);
|
||||
--status-info: oklch(0.65 0.1 210);
|
||||
--status-info-bg: oklch(0.65 0.1 210 / 0.2);
|
||||
--status-backlog: oklch(0.6 0.005 250);
|
||||
--status-in-progress: oklch(0.7 0.15 70);
|
||||
--status-waiting: oklch(0.68 0.1 220);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
@@ -1255,12 +1504,12 @@
|
||||
}
|
||||
|
||||
/* Custom scrollbar for dark themes */
|
||||
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red) ::-webkit-scrollbar {
|
||||
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red, .sunset, .gray) ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red) ::-webkit-scrollbar-track {
|
||||
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red, .sunset, .gray) ::-webkit-scrollbar-track {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
@@ -1296,6 +1545,62 @@
|
||||
background: oklch(0.15 0.05 25);
|
||||
}
|
||||
|
||||
/* Cream theme scrollbar */
|
||||
.cream ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.cream ::-webkit-scrollbar-thumb,
|
||||
.cream .scrollbar-visible::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.7 0.03 60);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.cream ::-webkit-scrollbar-thumb:hover,
|
||||
.cream .scrollbar-visible::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.6 0.04 60);
|
||||
}
|
||||
|
||||
.cream ::-webkit-scrollbar-track,
|
||||
.cream .scrollbar-visible::-webkit-scrollbar-track {
|
||||
background: oklch(0.9 0.015 70);
|
||||
}
|
||||
|
||||
/* Sunset theme scrollbar */
|
||||
.sunset ::-webkit-scrollbar-thumb,
|
||||
.sunset .scrollbar-visible::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.5 0.14 45);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sunset ::-webkit-scrollbar-thumb:hover,
|
||||
.sunset .scrollbar-visible::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.58 0.16 45);
|
||||
}
|
||||
|
||||
.sunset ::-webkit-scrollbar-track,
|
||||
.sunset .scrollbar-visible::-webkit-scrollbar-track {
|
||||
background: oklch(0.18 0.03 280);
|
||||
}
|
||||
|
||||
/* Gray theme scrollbar */
|
||||
.gray ::-webkit-scrollbar-thumb,
|
||||
.gray .scrollbar-visible::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.4 0.01 250);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.gray ::-webkit-scrollbar-thumb:hover,
|
||||
.gray .scrollbar-visible::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.5 0.02 250);
|
||||
}
|
||||
|
||||
.gray ::-webkit-scrollbar-track,
|
||||
.gray .scrollbar-visible::-webkit-scrollbar-track {
|
||||
background: oklch(0.25 0.005 250);
|
||||
}
|
||||
|
||||
/* Always visible scrollbar for file diffs and code blocks */
|
||||
.scrollbar-visible {
|
||||
overflow-y: auto !important;
|
||||
|
||||
@@ -192,7 +192,8 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
|
||||
resetFixtureSpec();
|
||||
});
|
||||
|
||||
test("should open project via file browser, edit spec, and persist", async ({
|
||||
// Skip in CI - file browser navigation is flaky in headless environments
|
||||
test.skip("should open project via file browser, edit spec, and persist", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to app first
|
||||
|
||||
Reference in New Issue
Block a user