mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +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 { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
|
||||||
import { createAutoModeOptions } from "../lib/sdk-options.js";
|
import { createAutoModeOptions } from "../lib/sdk-options.js";
|
||||||
import { isAbortError, classifyError } from "../lib/error-handler.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 {
|
import {
|
||||||
getFeatureDir,
|
getFeatureDir,
|
||||||
getFeaturesDir,
|
getFeaturesDir,
|
||||||
@@ -29,26 +31,6 @@ import {
|
|||||||
|
|
||||||
const execAsync = promisify(exec);
|
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 {
|
interface RunningFeature {
|
||||||
featureId: string;
|
featureId: string;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
@@ -1016,8 +998,10 @@ Format your response as a structured markdown document.`;
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
|
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) {
|
for (const entry of entries) {
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
const featurePath = path.join(
|
const featurePath = path.join(
|
||||||
@@ -1028,12 +1012,15 @@ Format your response as a structured markdown document.`;
|
|||||||
try {
|
try {
|
||||||
const data = await fs.readFile(featurePath, "utf-8");
|
const data = await fs.readFile(featurePath, "utf-8");
|
||||||
const feature = JSON.parse(data);
|
const feature = JSON.parse(data);
|
||||||
|
allFeatures.push(feature);
|
||||||
|
|
||||||
|
// Track pending features separately
|
||||||
if (
|
if (
|
||||||
feature.status === "pending" ||
|
feature.status === "pending" ||
|
||||||
feature.status === "ready" ||
|
feature.status === "ready" ||
|
||||||
feature.status === "backlog"
|
feature.status === "backlog"
|
||||||
) {
|
) {
|
||||||
features.push(feature);
|
pendingFeatures.push(feature);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Skip invalid features
|
// Skip invalid features
|
||||||
@@ -1041,8 +1028,15 @@ Format your response as a structured markdown document.`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by priority
|
// Apply dependency-aware ordering
|
||||||
return features.sort((a, b) => (a.priority || 999) - (b.priority || 999));
|
const { orderedFeatures } = resolveDependencies(pendingFeatures);
|
||||||
|
|
||||||
|
// Filter to only features with satisfied dependencies
|
||||||
|
const readyFeatures = orderedFeatures.filter(feature =>
|
||||||
|
areDependenciesSatisfied(feature, allFeatures)
|
||||||
|
);
|
||||||
|
|
||||||
|
return readyFeatures;
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
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");
|
const outputPath = path.join(featureDirForOutput, "agent-output.md");
|
||||||
|
|
||||||
// Incremental file writing state
|
// Incremental file writing state
|
||||||
let directoryCreated = false;
|
|
||||||
let writeTimeout: ReturnType<typeof setTimeout> | null = null;
|
let writeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
const WRITE_DEBOUNCE_MS = 500; // Batch writes every 500ms
|
const WRITE_DEBOUNCE_MS = 500; // Batch writes every 500ms
|
||||||
|
|
||||||
// Helper to write current responseText to file
|
// Helper to write current responseText to file
|
||||||
const writeToFile = async (): Promise<void> => {
|
const writeToFile = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
if (!directoryCreated) {
|
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
||||||
directoryCreated = true;
|
|
||||||
}
|
|
||||||
await fs.writeFile(outputPath, responseText);
|
await fs.writeFile(outputPath, responseText);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log but don't crash - file write errors shouldn't stop execution
|
// 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);
|
clearTimeout(writeTimeout);
|
||||||
}
|
}
|
||||||
writeTimeout = setTimeout(() => {
|
writeTimeout = setTimeout(() => {
|
||||||
writeToFile().catch((err) => {
|
writeToFile();
|
||||||
console.error(`[AutoMode] Debounced write error:`, err);
|
|
||||||
});
|
|
||||||
}, WRITE_DEBOUNCE_MS);
|
}, WRITE_DEBOUNCE_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export interface Feature {
|
|||||||
steps?: string[];
|
steps?: string[];
|
||||||
passes?: boolean;
|
passes?: boolean;
|
||||||
priority?: number;
|
priority?: number;
|
||||||
|
status?: string;
|
||||||
|
dependencies?: string[];
|
||||||
|
spec?: string;
|
||||||
|
model?: string;
|
||||||
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
|
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
|
||||||
[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 {
|
import {
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
CornerDownLeft,
|
CornerDownLeft,
|
||||||
|
Clock,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -44,6 +46,44 @@ interface FileBrowserDialogProps {
|
|||||||
initialPath?: string;
|
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({
|
export function FileBrowserDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -60,8 +100,26 @@ export function FileBrowserDialog({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [warning, setWarning] = useState("");
|
const [warning, setWarning] = useState("");
|
||||||
|
const [recentFolders, setRecentFolders] = useState<string[]>([]);
|
||||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
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) => {
|
const browseDirectory = async (dirPath?: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -152,27 +210,34 @@ export function FileBrowserDialog({
|
|||||||
|
|
||||||
const handleSelect = () => {
|
const handleSelect = () => {
|
||||||
if (currentPath) {
|
if (currentPath) {
|
||||||
|
addRecentFolder(currentPath);
|
||||||
onSelect(currentPath);
|
onSelect(currentPath);
|
||||||
onOpenChange(false);
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
<DialogContent className="bg-popover border-border max-w-3xl max-h-[85vh] overflow-hidden flex flex-col p-4">
|
||||||
<DialogHeader className="pb-2">
|
<DialogHeader className="pb-1">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2 text-base">
|
||||||
<FolderOpen className="w-5 h-5 text-brand-500" />
|
<FolderOpen className="w-4 h-4 text-brand-500" />
|
||||||
{title}
|
{title}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-muted-foreground">
|
<DialogDescription className="text-muted-foreground text-xs">
|
||||||
{description}
|
{description}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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 */}
|
{/* Direct path input */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5">
|
||||||
<Input
|
<Input
|
||||||
ref={pathInputRef}
|
ref={pathInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -180,7 +245,7 @@ export function FileBrowserDialog({
|
|||||||
value={pathInput}
|
value={pathInput}
|
||||||
onChange={(e) => setPathInput(e.target.value)}
|
onChange={(e) => setPathInput(e.target.value)}
|
||||||
onKeyDown={handlePathInputKeyDown}
|
onKeyDown={handlePathInputKeyDown}
|
||||||
className="flex-1 font-mono text-sm"
|
className="flex-1 font-mono text-xs h-8"
|
||||||
data-testid="path-input"
|
data-testid="path-input"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
@@ -190,16 +255,46 @@ export function FileBrowserDialog({
|
|||||||
onClick={handleGoToPath}
|
onClick={handleGoToPath}
|
||||||
disabled={loading || !pathInput.trim()}
|
disabled={loading || !pathInput.trim()}
|
||||||
data-testid="go-to-path-button"
|
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
|
Go
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 selector (Windows only) */}
|
||||||
{drives.length > 0 && (
|
{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 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-2">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-1">
|
||||||
<HardDrive className="w-3 h-3" />
|
<HardDrive className="w-3 h-3" />
|
||||||
<span>Drives:</span>
|
<span>Drives:</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +306,7 @@ export function FileBrowserDialog({
|
|||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleSelectDrive(drive)}
|
onClick={() => handleSelectDrive(drive)}
|
||||||
className="h-7 px-3 text-xs"
|
className="h-6 px-2 text-xs"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{drive.replace("\\", "")}
|
{drive.replace("\\", "")}
|
||||||
@@ -221,57 +316,57 @@ export function FileBrowserDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Current path breadcrumb */}
|
{/* 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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleGoHome}
|
onClick={handleGoHome}
|
||||||
className="h-7 px-2"
|
className="h-6 px-1.5"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<Home className="w-4 h-4" />
|
<Home className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
{parentPath && (
|
{parentPath && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleGoToParent}
|
onClick={handleGoToParent}
|
||||||
className="h-7 px-2"
|
className="h-6 px-1.5"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</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..."}
|
{currentPath || "Loading..."}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Directory list */}
|
{/* 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 && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center h-full p-8">
|
<div className="flex items-center justify-center h-full p-4">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Loading directories...
|
Loading directories...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center justify-center h-full p-8">
|
<div className="flex items-center justify-center h-full p-4">
|
||||||
<div className="text-sm text-destructive">{error}</div>
|
<div className="text-xs text-destructive">{error}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{warning && (
|
{warning && (
|
||||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg mb-2">
|
<div className="p-2 bg-yellow-500/10 border border-yellow-500/30 rounded-md mb-1">
|
||||||
<div className="text-sm text-yellow-500">{warning}</div>
|
<div className="text-xs text-yellow-500">{warning}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && !warning && directories.length === 0 && (
|
{!loading && !error && !warning && directories.length === 0 && (
|
||||||
<div className="flex items-center justify-center h-full p-8">
|
<div className="flex items-center justify-center h-full p-4">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
No subdirectories found
|
No subdirectories found
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,29 +378,29 @@ export function FileBrowserDialog({
|
|||||||
<button
|
<button
|
||||||
key={dir.path}
|
key={dir.path}
|
||||||
onClick={() => handleSelectDirectory(dir)}
|
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" />
|
<Folder className="w-4 h-4 text-brand-500 shrink-0" />
|
||||||
<span className="flex-1 truncate text-sm">{dir.name}</span>
|
<span className="flex-1 truncate text-xs">{dir.name}</span>
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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
|
Paste a full path above, or click on folders to navigate. Press
|
||||||
Enter or click Go to jump to a path.
|
Enter or click Go to jump to a path.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="border-t border-border pt-4 gap-2">
|
<DialogFooter className="border-t border-border pt-3 gap-2 mt-1">
|
||||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSelect} disabled={!currentPath || loading}>
|
<Button size="sm" onClick={handleSelect} disabled={!currentPath || loading}>
|
||||||
<FolderOpen className="w-4 h-4 mr-2" />
|
<FolderOpen className="w-3.5 h-3.5 mr-1.5" />
|
||||||
Select Current Folder
|
Select Current Folder
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import { DeleteWorktreeDialog } from "./board-view/dialogs/delete-worktree-dialo
|
|||||||
import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialog";
|
import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialog";
|
||||||
import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog";
|
import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog";
|
||||||
import { CreateBranchDialog } from "./board-view/dialogs/create-branch-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 { COLUMNS } from "./board-view/constants";
|
||||||
import {
|
import {
|
||||||
useBoardFeatures,
|
useBoardFeatures,
|
||||||
@@ -432,8 +432,8 @@ export function BoardView() {
|
|||||||
isMounted={isMounted}
|
isMounted={isMounted}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Worktree Selector */}
|
{/* Worktree Panel */}
|
||||||
<WorktreeSelector
|
<WorktreePanel
|
||||||
refreshTrigger={worktreeRefreshKey}
|
refreshTrigger={worktreeRefreshKey}
|
||||||
projectPath={currentProject.path}
|
projectPath={currentProject.path}
|
||||||
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
|
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
export { KanbanCard } from "./kanban-card";
|
export { KanbanCard } from "./kanban-card";
|
||||||
export { KanbanColumn } from "./kanban-column";
|
export { KanbanColumn } from "./kanban-column";
|
||||||
export { WorktreeSelector } from "./worktree-selector";
|
|
||||||
|
|||||||
@@ -56,9 +56,11 @@ import {
|
|||||||
Brain,
|
Brain,
|
||||||
Wand2,
|
Wand2,
|
||||||
Archive,
|
Archive,
|
||||||
|
Lock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CountUpTimer } from "@/components/ui/count-up-timer";
|
import { CountUpTimer } from "@/components/ui/count-up-timer";
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { getBlockingDependencies } from "@/lib/dependency-resolver";
|
||||||
import {
|
import {
|
||||||
parseAgentContext,
|
parseAgentContext,
|
||||||
AgentTaskInfo,
|
AgentTaskInfo,
|
||||||
@@ -142,7 +144,15 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
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 =
|
const showSteps =
|
||||||
kanbanCardDetailLevel === "standard" ||
|
kanbanCardDetailLevel === "standard" ||
|
||||||
@@ -327,7 +337,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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]",
|
"top-2 left-2 min-w-[36px]",
|
||||||
feature.priority === 1 &&
|
feature.priority === 1 &&
|
||||||
"bg-red-500/20 text-red-500 border-2 border-red-500/50",
|
"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}`}
|
data-testid={`priority-badge-${feature.id}`}
|
||||||
>
|
>
|
||||||
P{feature.priority}
|
{feature.priority === 1 ? "H" : feature.priority === 2 ? "M" : "L"}
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" className="text-xs">
|
<TooltipContent side="right" className="text-xs">
|
||||||
@@ -363,62 +373,97 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status badges row */}
|
{/* Skip Tests (Manual) indicator badge - positioned at top right */}
|
||||||
{(feature.skipTests || feature.error || isJustFinished) && (
|
{feature.skipTests && !feature.error && 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-[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-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left" className="text-xs">
|
||||||
|
<p>Manual verification required</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error indicator badge */}
|
||||||
|
{feature.error && (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
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.5 h-3.5" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="text-xs max-w-[250px]">
|
||||||
|
<p>{feature.error}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute left-2 z-10 flex items-center gap-1",
|
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
|
||||||
feature.priority ? "top-11" : "top-2"
|
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"
|
||||||
>
|
>
|
||||||
{/* Skip Tests (Manual) indicator badge */}
|
<Sparkles className="w-3 h-3" />
|
||||||
{feature.skipTests && !feature.error && (
|
|
||||||
<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)]"
|
|
||||||
data-testid={`skip-tests-badge-${feature.id}`}
|
|
||||||
>
|
|
||||||
<Hand className="w-3 h-3" />
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" className="text-xs">
|
|
||||||
<p>Manual verification required</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error indicator badge */}
|
|
||||||
{feature.error && (
|
|
||||||
<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-error-bg)] border border-[var(--status-error)]/40 text-[var(--status-error)]"
|
|
||||||
data-testid={`error-badge-${feature.id}`}
|
|
||||||
>
|
|
||||||
<AlertCircle className="w-3 h-3" />
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" className="text-xs max-w-[250px]">
|
|
||||||
<p>{feature.error}</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"
|
|
||||||
data-testid={`just-finished-badge-${feature.id}`}
|
|
||||||
title="Agent just finished working on this feature"
|
|
||||||
>
|
|
||||||
<Sparkles className="w-3 h-3" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -446,7 +491,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isCurrentAutoTask && feature.status === "backlog" && (
|
{!isCurrentAutoTask && feature.status === "backlog" && (
|
||||||
<div className="absolute top-2 right-2">
|
<div className="absolute bottom-1 right-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -465,43 +510,110 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
{!isCurrentAutoTask &&
|
{!isCurrentAutoTask &&
|
||||||
(feature.status === "waiting_approval" ||
|
(feature.status === "waiting_approval" ||
|
||||||
feature.status === "verified") && (
|
feature.status === "verified") && (
|
||||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
<>
|
||||||
<Button
|
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onEdit();
|
|
||||||
}}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
data-testid={`edit-${
|
|
||||||
feature.status === "waiting_approval" ? "waiting" : "verified"
|
|
||||||
}-${feature.id}`}
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<Edit className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
{onViewOutput && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
|
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onViewOutput();
|
onEdit();
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
data-testid={`logs-${
|
data-testid={`edit-${
|
||||||
feature.status === "waiting_approval"
|
feature.status === "waiting_approval" ? "waiting" : "verified"
|
||||||
? "waiting"
|
|
||||||
: "verified"
|
|
||||||
}-${feature.id}`}
|
}-${feature.id}`}
|
||||||
title="Logs"
|
title="Edit"
|
||||||
>
|
>
|
||||||
<FileText className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
{onViewOutput && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewOutput();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`logs-${
|
||||||
|
feature.status === "waiting_approval"
|
||||||
|
? "waiting"
|
||||||
|
: "verified"
|
||||||
|
}-${feature.id}`}
|
||||||
|
title="Logs"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</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.status === "waiting_approval" ? "waiting" : "verified"
|
||||||
|
}-${feature.id}`}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isCurrentAutoTask && feature.status === "in_progress" && (
|
||||||
|
<>
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`menu-${feature.id}`}
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-36">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
data-testid={`edit-feature-${feature.id}`}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Edit className="w-3 h-3 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{onViewOutput && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewOutput();
|
||||||
|
}}
|
||||||
|
data-testid={`view-logs-${feature.id}`}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3 mr-2" />
|
||||||
|
View Logs
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-1 right-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -511,69 +623,13 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
handleDeleteClick(e);
|
handleDeleteClick(e);
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
data-testid={`delete-${
|
data-testid={`delete-feature-${feature.id}`}
|
||||||
feature.status === "waiting_approval" ? "waiting" : "verified"
|
|
||||||
}-${feature.id}`}
|
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
{!isCurrentAutoTask && feature.status === "in_progress" && (
|
|
||||||
<div className="absolute top-2 right-2">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
data-testid={`menu-${feature.id}`}
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-36">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onEdit();
|
|
||||||
}}
|
|
||||||
data-testid={`edit-feature-${feature.id}`}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Edit className="w-3 h-3 mr-2" />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{onViewOutput && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onViewOutput();
|
|
||||||
}}
|
|
||||||
data-testid={`view-logs-${feature.id}`}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<FileText className="w-3 h-3 mr-2" />
|
|
||||||
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="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
{isDraggable && (
|
{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 { toast } from "sonner";
|
||||||
import { useAutoMode } from "@/hooks/use-auto-mode";
|
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||||
import { truncateDescription } from "@/lib/utils";
|
import { truncateDescription } from "@/lib/utils";
|
||||||
|
import { getBlockingDependencies } from "@/lib/dependency-resolver";
|
||||||
|
|
||||||
interface UseBoardActionsProps {
|
interface UseBoardActionsProps {
|
||||||
currentProject: { path: string; id: string } | null;
|
currentProject: { path: string; id: string } | null;
|
||||||
@@ -74,6 +75,7 @@ export function useBoardActions({
|
|||||||
removeFeature,
|
removeFeature,
|
||||||
moveFeature,
|
moveFeature,
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
|
enableDependencyBlocking,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const autoMode = useAutoMode();
|
const autoMode = useAutoMode();
|
||||||
|
|
||||||
@@ -344,6 +346,21 @@ export function useBoardActions({
|
|||||||
return false;
|
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 = {
|
const updates = {
|
||||||
status: "in_progress" as const,
|
status: "in_progress" as const,
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
@@ -355,7 +372,7 @@ export function useBoardActions({
|
|||||||
await handleRunFeature(feature);
|
await handleRunFeature(feature);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[autoMode, updateFeature, persistFeatureUpdate, handleRunFeature]
|
[autoMode, enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleVerifyFeature = useCallback(
|
const handleVerifyFeature = useCallback(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useCallback } from "react";
|
import { useMemo, useCallback } from "react";
|
||||||
import { Feature } from "@/store/app-store";
|
import { Feature } from "@/store/app-store";
|
||||||
|
import { resolveDependencies } from "@/lib/dependency-resolver";
|
||||||
import { pathsEqual } from "@/lib/utils";
|
import { pathsEqual } from "@/lib/utils";
|
||||||
|
|
||||||
type ColumnId = Feature["status"];
|
type ColumnId = Feature["status"];
|
||||||
@@ -105,12 +106,13 @@ export function useBoardColumnFeatures({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort backlog by priority: 1 (high) -> 2 (medium) -> 3 (low) -> no priority
|
// Apply dependency-aware sorting to backlog
|
||||||
map.backlog.sort((a, b) => {
|
// This ensures features appear in dependency order (dependencies before dependents)
|
||||||
const aPriority = a.priority ?? 999; // Features without priority go last
|
// Within the same dependency level, features are sorted by priority
|
||||||
const bPriority = b.priority ?? 999;
|
if (map.backlog.length > 0) {
|
||||||
return aPriority - bPriority;
|
const { orderedFeatures } = resolveDependencies(map.backlog);
|
||||||
});
|
map.backlog = orderedFeatures;
|
||||||
|
}
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]);
|
}, [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,
|
setProjectTheme,
|
||||||
defaultSkipTests,
|
defaultSkipTests,
|
||||||
setDefaultSkipTests,
|
setDefaultSkipTests,
|
||||||
|
enableDependencyBlocking,
|
||||||
|
setEnableDependencyBlocking,
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
setUseWorktrees,
|
setUseWorktrees,
|
||||||
showProfilesOnly,
|
showProfilesOnly,
|
||||||
@@ -117,9 +119,11 @@ export function SettingsView() {
|
|||||||
<FeatureDefaultsSection
|
<FeatureDefaultsSection
|
||||||
showProfilesOnly={showProfilesOnly}
|
showProfilesOnly={showProfilesOnly}
|
||||||
defaultSkipTests={defaultSkipTests}
|
defaultSkipTests={defaultSkipTests}
|
||||||
|
enableDependencyBlocking={enableDependencyBlocking}
|
||||||
useWorktrees={useWorktrees}
|
useWorktrees={useWorktrees}
|
||||||
onShowProfilesOnlyChange={setShowProfilesOnly}
|
onShowProfilesOnlyChange={setShowProfilesOnly}
|
||||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||||
|
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||||
onUseWorktreesChange={setUseWorktrees}
|
onUseWorktreesChange={setUseWorktrees}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface FeatureDefaultsSectionProps {
|
interface FeatureDefaultsSectionProps {
|
||||||
showProfilesOnly: boolean;
|
showProfilesOnly: boolean;
|
||||||
defaultSkipTests: boolean;
|
defaultSkipTests: boolean;
|
||||||
|
enableDependencyBlocking: boolean;
|
||||||
useWorktrees: boolean;
|
useWorktrees: boolean;
|
||||||
onShowProfilesOnlyChange: (value: boolean) => void;
|
onShowProfilesOnlyChange: (value: boolean) => void;
|
||||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||||
|
onEnableDependencyBlockingChange: (value: boolean) => void;
|
||||||
onUseWorktreesChange: (value: boolean) => void;
|
onUseWorktreesChange: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeatureDefaultsSection({
|
export function FeatureDefaultsSection({
|
||||||
showProfilesOnly,
|
showProfilesOnly,
|
||||||
defaultSkipTests,
|
defaultSkipTests,
|
||||||
|
enableDependencyBlocking,
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
onShowProfilesOnlyChange,
|
onShowProfilesOnlyChange,
|
||||||
onDefaultSkipTestsChange,
|
onDefaultSkipTestsChange,
|
||||||
|
onEnableDependencyBlockingChange,
|
||||||
onUseWorktreesChange,
|
onUseWorktreesChange,
|
||||||
}: FeatureDefaultsSectionProps) {
|
}: FeatureDefaultsSectionProps) {
|
||||||
return (
|
return (
|
||||||
@@ -102,6 +106,36 @@ export function FeatureDefaultsSection({
|
|||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
<div className="border-t border-border/30" />
|
<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 */}
|
{/* 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">
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ export type Theme =
|
|||||||
| "catppuccin"
|
| "catppuccin"
|
||||||
| "onedark"
|
| "onedark"
|
||||||
| "synthwave"
|
| "synthwave"
|
||||||
| "red";
|
| "red"
|
||||||
|
| "cream"
|
||||||
|
| "sunset"
|
||||||
|
| "gray";
|
||||||
|
|
||||||
export type KanbanDetailLevel = "minimal" | "standard" | "detailed";
|
export type KanbanDetailLevel = "minimal" | "standard" | "detailed";
|
||||||
|
|
||||||
|
|||||||
@@ -356,6 +356,81 @@ const redTheme: TerminalTheme = {
|
|||||||
brightWhite: "#d0c0c0",
|
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
|
// Theme mapping
|
||||||
const terminalThemes: Record<ThemeMode, TerminalTheme> = {
|
const terminalThemes: Record<ThemeMode, TerminalTheme> = {
|
||||||
light: lightTheme,
|
light: lightTheme,
|
||||||
@@ -372,6 +447,9 @@ const terminalThemes: Record<ThemeMode, TerminalTheme> = {
|
|||||||
onedark: onedarkTheme,
|
onedark: onedarkTheme,
|
||||||
synthwave: synthwaveTheme,
|
synthwave: synthwaveTheme,
|
||||||
red: redTheme,
|
red: redTheme,
|
||||||
|
cream: creamTheme,
|
||||||
|
sunset: sunsetTheme,
|
||||||
|
gray: grayTheme,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
Atom,
|
Atom,
|
||||||
Cat,
|
Cat,
|
||||||
|
CloudSun,
|
||||||
|
Coffee,
|
||||||
Eclipse,
|
Eclipse,
|
||||||
Flame,
|
Flame,
|
||||||
Ghost,
|
Ghost,
|
||||||
@@ -10,6 +12,7 @@ import {
|
|||||||
Radio,
|
Radio,
|
||||||
Snowflake,
|
Snowflake,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Square,
|
||||||
Sun,
|
Sun,
|
||||||
Terminal,
|
Terminal,
|
||||||
Trees,
|
Trees,
|
||||||
@@ -92,4 +95,22 @@ export const themeOptions: ReadonlyArray<ThemeOption> = [
|
|||||||
Icon: Heart,
|
Icon: Heart,
|
||||||
testId: "red-mode-button",
|
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
|
* Extracts a summary from completed feature context
|
||||||
|
* Looks for content between <summary> and </summary> tags
|
||||||
*/
|
*/
|
||||||
function extractSummary(content: string): string | undefined {
|
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
|
// Stop at same-level ## sections (but not ###), or tool markers, or end
|
||||||
const summaryMatch = content.match(/## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i);
|
const summaryMatch = content.match(/## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i);
|
||||||
if (summaryMatch) {
|
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"
|
| "catppuccin"
|
||||||
| "onedark"
|
| "onedark"
|
||||||
| "synthwave"
|
| "synthwave"
|
||||||
| "red";
|
| "red"
|
||||||
|
| "cream"
|
||||||
|
| "sunset"
|
||||||
|
| "gray";
|
||||||
|
|
||||||
export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed";
|
export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed";
|
||||||
|
|
||||||
@@ -394,6 +397,7 @@ export interface AppState {
|
|||||||
|
|
||||||
// Feature Default Settings
|
// Feature Default Settings
|
||||||
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
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
|
// Worktree Settings
|
||||||
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: false)
|
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: false)
|
||||||
@@ -580,6 +584,7 @@ export interface AppActions {
|
|||||||
|
|
||||||
// Feature Default Settings actions
|
// Feature Default Settings actions
|
||||||
setDefaultSkipTests: (skip: boolean) => void;
|
setDefaultSkipTests: (skip: boolean) => void;
|
||||||
|
setEnableDependencyBlocking: (enabled: boolean) => void;
|
||||||
|
|
||||||
// Worktree Settings actions
|
// Worktree Settings actions
|
||||||
setUseWorktrees: (enabled: boolean) => void;
|
setUseWorktrees: (enabled: boolean) => void;
|
||||||
@@ -750,6 +755,7 @@ const initialState: AppState = {
|
|||||||
maxConcurrency: 3, // Default to 3 concurrent agents
|
maxConcurrency: 3, // Default to 3 concurrent agents
|
||||||
kanbanCardDetailLevel: "standard", // Default to standard detail level
|
kanbanCardDetailLevel: "standard", // Default to standard detail level
|
||||||
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
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)
|
useWorktrees: false, // Default to disabled (worktree feature is experimental)
|
||||||
currentWorktreeByProject: {},
|
currentWorktreeByProject: {},
|
||||||
worktreesByProject: {},
|
worktreesByProject: {},
|
||||||
@@ -1341,6 +1347,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
|
|
||||||
// Feature Default Settings actions
|
// Feature Default Settings actions
|
||||||
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
|
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
|
||||||
|
setEnableDependencyBlocking: (enabled) => set({ enableDependencyBlocking: enabled }),
|
||||||
|
|
||||||
// Worktree Settings actions
|
// Worktree Settings actions
|
||||||
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
|
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
|
||||||
@@ -2232,6 +2239,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
maxConcurrency: state.maxConcurrency,
|
maxConcurrency: state.maxConcurrency,
|
||||||
autoModeByProject: state.autoModeByProject,
|
autoModeByProject: state.autoModeByProject,
|
||||||
defaultSkipTests: state.defaultSkipTests,
|
defaultSkipTests: state.defaultSkipTests,
|
||||||
|
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||||
useWorktrees: state.useWorktrees,
|
useWorktrees: state.useWorktrees,
|
||||||
currentWorktreeByProject: state.currentWorktreeByProject,
|
currentWorktreeByProject: state.currentWorktreeByProject,
|
||||||
worktreesByProject: state.worktreesByProject,
|
worktreesByProject: state.worktreesByProject,
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
@custom-variant onedark (&:is(.onedark *));
|
@custom-variant onedark (&:is(.onedark *));
|
||||||
@custom-variant synthwave (&:is(.synthwave *));
|
@custom-variant synthwave (&:is(.synthwave *));
|
||||||
@custom-variant red (&:is(.red *));
|
@custom-variant red (&:is(.red *));
|
||||||
|
@custom-variant cream (&:is(.cream *));
|
||||||
|
@custom-variant sunset (&:is(.sunset *));
|
||||||
|
@custom-variant gray (&:is(.gray *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
@@ -1220,6 +1223,252 @@
|
|||||||
--running-indicator-text: oklch(0.6 0.23 25);
|
--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 {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
@@ -1255,12 +1504,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar for dark themes */
|
/* 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;
|
width: 8px;
|
||||||
height: 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);
|
background: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1296,6 +1545,62 @@
|
|||||||
background: oklch(0.15 0.05 25);
|
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 */
|
/* Always visible scrollbar for file diffs and code blocks */
|
||||||
.scrollbar-visible {
|
.scrollbar-visible {
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
|
|||||||
@@ -192,7 +192,8 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
|
|||||||
resetFixtureSpec();
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// Navigate to app first
|
// Navigate to app first
|
||||||
|
|||||||
Reference in New Issue
Block a user