mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
feat: enhance worktree listing by scanning external directories
- Implemented a new function to scan the .worktrees directory for worktrees that may exist outside of git's management, allowing for better detection of externally created or corrupted worktrees. - Updated the /list endpoint to include discovered worktrees in the response, improving the accuracy of the worktree listing. - Added logging for discovered worktrees to aid in debugging and tracking. - Cleaned up and organized imports in the list.ts file for better maintainability.
This commit is contained in:
@@ -2,18 +2,23 @@
|
|||||||
* POST /list endpoint - List all git worktrees
|
* POST /list endpoint - List all git worktrees
|
||||||
*
|
*
|
||||||
* Returns actual git worktrees from `git worktree list`.
|
* Returns actual git worktrees from `git worktree list`.
|
||||||
|
* Also scans .worktrees/ directory to discover worktrees that may have been
|
||||||
|
* created externally or whose git state was corrupted.
|
||||||
* Does NOT include tracked branches - only real worktrees with separate directories.
|
* Does NOT include tracked branches - only real worktrees with separate directories.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
|
import path from 'path';
|
||||||
import * as secureFs from '../../../lib/secure-fs.js';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import { isGitRepo } from '@automaker/git-utils';
|
import { isGitRepo } from '@automaker/git-utils';
|
||||||
import { getErrorMessage, logError, normalizePath } from '../common.js';
|
import { getErrorMessage, logError, normalizePath } from '../common.js';
|
||||||
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
const logger = createLogger('Worktree');
|
||||||
|
|
||||||
interface WorktreeInfo {
|
interface WorktreeInfo {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -35,6 +40,87 @@ async function getCurrentBranch(cwd: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan the .worktrees directory to discover worktrees that may exist on disk
|
||||||
|
* but are not registered with git (e.g., created externally or corrupted state).
|
||||||
|
*/
|
||||||
|
async function scanWorktreesDirectory(
|
||||||
|
projectPath: string,
|
||||||
|
knownWorktreePaths: Set<string>
|
||||||
|
): Promise<Array<{ path: string; branch: string }>> {
|
||||||
|
const discovered: Array<{ path: string; branch: string }> = [];
|
||||||
|
const worktreesDir = path.join(projectPath, '.worktrees');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if .worktrees directory exists
|
||||||
|
await secureFs.access(worktreesDir);
|
||||||
|
} catch {
|
||||||
|
// .worktrees directory doesn't exist
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await secureFs.readdir(worktreesDir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
|
||||||
|
const worktreePath = path.join(worktreesDir, entry.name);
|
||||||
|
const normalizedPath = normalizePath(worktreePath);
|
||||||
|
|
||||||
|
// Skip if already known from git worktree list
|
||||||
|
if (knownWorktreePaths.has(normalizedPath)) continue;
|
||||||
|
|
||||||
|
// Check if this is a valid git repository
|
||||||
|
const gitPath = path.join(worktreePath, '.git');
|
||||||
|
try {
|
||||||
|
const gitStat = await secureFs.stat(gitPath);
|
||||||
|
|
||||||
|
// Git worktrees have a .git FILE (not directory) that points to the parent repo
|
||||||
|
// Regular repos have a .git DIRECTORY
|
||||||
|
if (gitStat.isFile() || gitStat.isDirectory()) {
|
||||||
|
// Try to get the branch name
|
||||||
|
const branch = await getCurrentBranch(worktreePath);
|
||||||
|
if (branch) {
|
||||||
|
logger.info(
|
||||||
|
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${branch})`
|
||||||
|
);
|
||||||
|
discovered.push({
|
||||||
|
path: normalizedPath,
|
||||||
|
branch,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Try to get branch from HEAD if branch --show-current fails (detached HEAD)
|
||||||
|
try {
|
||||||
|
const { stdout: headRef } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||||
|
cwd: worktreePath,
|
||||||
|
});
|
||||||
|
const headBranch = headRef.trim();
|
||||||
|
if (headBranch && headBranch !== 'HEAD') {
|
||||||
|
logger.info(
|
||||||
|
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})`
|
||||||
|
);
|
||||||
|
discovered.push({
|
||||||
|
path: normalizedPath,
|
||||||
|
branch: headBranch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Can't determine branch, skip this directory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not a git repo, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to scan .worktrees directory: ${getErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
export function createListHandler() {
|
export function createListHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -116,6 +202,22 @@ export function createListHandler() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scan .worktrees directory to discover worktrees that exist on disk
|
||||||
|
// but are not registered with git (e.g., created externally)
|
||||||
|
const knownPaths = new Set(worktrees.map((w) => w.path));
|
||||||
|
const discoveredWorktrees = await scanWorktreesDirectory(projectPath, knownPaths);
|
||||||
|
|
||||||
|
// Add discovered worktrees to the list
|
||||||
|
for (const discovered of discoveredWorktrees) {
|
||||||
|
worktrees.push({
|
||||||
|
path: discovered.path,
|
||||||
|
branch: discovered.branch,
|
||||||
|
isMain: false,
|
||||||
|
isCurrent: discovered.branch === currentBranch,
|
||||||
|
hasWorktree: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Read all worktree metadata to get PR info
|
// Read all worktree metadata to get PR info
|
||||||
const allMetadata = await readAllWorktreeMetadata(projectPath);
|
const allMetadata = await readAllWorktreeMetadata(projectPath);
|
||||||
|
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
|||||||
import { useWindowState } from '@/hooks/use-window-state';
|
import { useWindowState } from '@/hooks/use-window-state';
|
||||||
// Board-view specific imports
|
// Board-view specific imports
|
||||||
import { BoardHeader } from './board-view/board-header';
|
import { BoardHeader } from './board-view/board-header';
|
||||||
import { BoardSearchBar } from './board-view/board-search-bar';
|
|
||||||
import { BoardControls } from './board-view/board-controls';
|
|
||||||
import { KanbanBoard } from './board-view/kanban-board';
|
import { KanbanBoard } from './board-view/kanban-board';
|
||||||
import { GraphView } from './graph-view';
|
import { GraphView } from './graph-view';
|
||||||
import {
|
import {
|
||||||
@@ -1155,7 +1153,6 @@ export function BoardView() {
|
|||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<BoardHeader
|
<BoardHeader
|
||||||
projectName={currentProject.name}
|
|
||||||
projectPath={currentProject.path}
|
projectPath={currentProject.path}
|
||||||
maxConcurrency={maxConcurrency}
|
maxConcurrency={maxConcurrency}
|
||||||
runningAgentsCount={runningAutoTasks.length}
|
runningAgentsCount={runningAutoTasks.length}
|
||||||
@@ -1170,6 +1167,15 @@ export function BoardView() {
|
|||||||
}}
|
}}
|
||||||
onOpenPlanDialog={() => setShowPlanDialog(true)}
|
onOpenPlanDialog={() => setShowPlanDialog(true)}
|
||||||
isMounted={isMounted}
|
isMounted={isMounted}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
isCreatingSpec={isCreatingSpec}
|
||||||
|
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||||
|
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
||||||
|
onShowCompletedModal={() => setShowCompletedModal(true)}
|
||||||
|
completedCount={completedFeatures.length}
|
||||||
|
boardViewMode={boardViewMode}
|
||||||
|
onBoardViewModeChange={setBoardViewMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Worktree Panel - conditionally rendered based on visibility setting */}
|
{/* Worktree Panel - conditionally rendered based on visibility setting */}
|
||||||
@@ -1208,26 +1214,6 @@ export function BoardView() {
|
|||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Search Bar Row */}
|
|
||||||
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
|
|
||||||
<BoardSearchBar
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
onSearchChange={setSearchQuery}
|
|
||||||
isCreatingSpec={isCreatingSpec}
|
|
||||||
creatingSpecProjectPath={creatingSpecProjectPath ?? undefined}
|
|
||||||
currentProjectPath={currentProject?.path}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Board Background & Detail Level Controls */}
|
|
||||||
<BoardControls
|
|
||||||
isMounted={isMounted}
|
|
||||||
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
|
||||||
onShowCompletedModal={() => setShowCompletedModal(true)}
|
|
||||||
completedCount={completedFeatures.length}
|
|
||||||
boardViewMode={boardViewMode}
|
|
||||||
onBoardViewModeChange={setBoardViewMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* View Content - Kanban or Graph */}
|
{/* View Content - Kanban or Graph */}
|
||||||
{boardViewMode === 'kanban' ? (
|
{boardViewMode === 'kanban' ? (
|
||||||
<KanbanBoard
|
<KanbanBoard
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function BoardControls({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="flex items-center gap-2">
|
||||||
{/* View Mode Toggle - Kanban / Graph */}
|
{/* View Mode Toggle - Kanban / Graph */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center rounded-lg bg-secondary border border-border"
|
className="flex items-center rounded-lg bg-secondary border border-border"
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Bot, Wand2, Settings2, GitBranch } from 'lucide-react';
|
import { Bot, Wand2, Settings2, GitBranch } from 'lucide-react';
|
||||||
import { UsagePopover } from '@/components/usage-popover';
|
import { UsagePopover } from '@/components/usage-popover';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore, BoardViewMode } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
|
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import { BoardSearchBar } from './board-search-bar';
|
||||||
|
import { BoardControls } from './board-controls';
|
||||||
|
|
||||||
interface BoardHeaderProps {
|
interface BoardHeaderProps {
|
||||||
projectName: string;
|
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
maxConcurrency: number;
|
maxConcurrency: number;
|
||||||
runningAgentsCount: number;
|
runningAgentsCount: number;
|
||||||
@@ -21,6 +22,17 @@ interface BoardHeaderProps {
|
|||||||
onAutoModeToggle: (enabled: boolean) => void;
|
onAutoModeToggle: (enabled: boolean) => void;
|
||||||
onOpenPlanDialog: () => void;
|
onOpenPlanDialog: () => void;
|
||||||
isMounted: boolean;
|
isMounted: boolean;
|
||||||
|
// Search bar props
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchChange: (query: string) => void;
|
||||||
|
isCreatingSpec: boolean;
|
||||||
|
creatingSpecProjectPath?: string;
|
||||||
|
// Board controls props
|
||||||
|
onShowBoardBackground: () => void;
|
||||||
|
onShowCompletedModal: () => void;
|
||||||
|
completedCount: number;
|
||||||
|
boardViewMode: BoardViewMode;
|
||||||
|
onBoardViewModeChange: (mode: BoardViewMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared styles for header control containers
|
// Shared styles for header control containers
|
||||||
@@ -28,7 +40,6 @@ const controlContainerClass =
|
|||||||
'flex items-center gap-1.5 px-3 h-8 rounded-md bg-secondary border border-border';
|
'flex items-center gap-1.5 px-3 h-8 rounded-md bg-secondary border border-border';
|
||||||
|
|
||||||
export function BoardHeader({
|
export function BoardHeader({
|
||||||
projectName,
|
|
||||||
projectPath,
|
projectPath,
|
||||||
maxConcurrency,
|
maxConcurrency,
|
||||||
runningAgentsCount,
|
runningAgentsCount,
|
||||||
@@ -37,6 +48,15 @@ export function BoardHeader({
|
|||||||
onAutoModeToggle,
|
onAutoModeToggle,
|
||||||
onOpenPlanDialog,
|
onOpenPlanDialog,
|
||||||
isMounted,
|
isMounted,
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
isCreatingSpec,
|
||||||
|
creatingSpecProjectPath,
|
||||||
|
onShowBoardBackground,
|
||||||
|
onShowCompletedModal,
|
||||||
|
completedCount,
|
||||||
|
boardViewMode,
|
||||||
|
onBoardViewModeChange,
|
||||||
}: BoardHeaderProps) {
|
}: BoardHeaderProps) {
|
||||||
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
|
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
|
||||||
const apiKeys = useAppStore((state) => state.apiKeys);
|
const apiKeys = useAppStore((state) => state.apiKeys);
|
||||||
@@ -84,9 +104,22 @@ export function BoardHeader({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||||
<div>
|
<div className="flex items-center gap-4">
|
||||||
<h1 className="text-xl font-bold">Kanban Board</h1>
|
<BoardSearchBar
|
||||||
<p className="text-sm text-muted-foreground">{projectName}</p>
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
isCreatingSpec={isCreatingSpec}
|
||||||
|
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||||
|
currentProjectPath={projectPath}
|
||||||
|
/>
|
||||||
|
<BoardControls
|
||||||
|
isMounted={isMounted}
|
||||||
|
onShowBoardBackground={onShowBoardBackground}
|
||||||
|
onShowCompletedModal={onShowCompletedModal}
|
||||||
|
completedCount={completedCount}
|
||||||
|
boardViewMode={boardViewMode}
|
||||||
|
onBoardViewModeChange={onBoardViewModeChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
{/* Usage Popover - show if either provider is authenticated */}
|
{/* Usage Popover - show if either provider is authenticated */}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function SummaryDialog({
|
|||||||
data-testid={`summary-dialog-${feature.id}`}
|
data-testid={`summary-dialog-${feature.id}`}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export function KanbanBoard({
|
|||||||
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
|
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-x-auto px-5 pb-4 relative" style={backgroundImageStyle}>
|
<div className="flex-1 overflow-x-auto px-5 pt-4 pb-4 relative" style={backgroundImageStyle}>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={collisionDetectionStrategy}
|
collisionDetection={collisionDetectionStrategy}
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ export function GraphControls({
|
|||||||
return (
|
return (
|
||||||
<Panel position="bottom-left" className="flex flex-col gap-2">
|
<Panel position="bottom-left" className="flex flex-col gap-2">
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<div className="flex flex-col gap-1 p-1.5 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
|
<div
|
||||||
|
className="flex flex-col gap-1 p-1.5 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
|
||||||
|
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
|
||||||
|
>
|
||||||
{/* Zoom controls */}
|
{/* Zoom controls */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -110,7 +110,10 @@ export function GraphFilterControls({
|
|||||||
return (
|
return (
|
||||||
<Panel position="top-left" className="flex items-center gap-2">
|
<Panel position="top-left" className="flex items-center gap-2">
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
|
<div
|
||||||
|
className="flex items-center gap-2 p-2 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
|
||||||
|
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
|
||||||
|
>
|
||||||
{/* Category Filter Dropdown */}
|
{/* Category Filter Dropdown */}
|
||||||
<Popover>
|
<Popover>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ const legendItems = [
|
|||||||
export function GraphLegend() {
|
export function GraphLegend() {
|
||||||
return (
|
return (
|
||||||
<Panel position="bottom-right" className="pointer-events-none">
|
<Panel position="bottom-right" className="pointer-events-none">
|
||||||
<div className="flex flex-wrap gap-3 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg pointer-events-auto text-popover-foreground">
|
<div
|
||||||
|
className="flex flex-wrap gap-3 p-2 rounded-lg backdrop-blur-sm border border-border shadow-lg pointer-events-auto text-popover-foreground"
|
||||||
|
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
|
||||||
|
>
|
||||||
{legendItems.map((item) => {
|
{legendItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -75,6 +75,24 @@ const priorityConfig = {
|
|||||||
3: { label: 'Low', colorClass: 'bg-[var(--status-info)] text-white' },
|
3: { label: 'Low', colorClass: 'bg-[var(--status-info)] text-white' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper function to get border style with opacity (like KanbanCard does)
|
||||||
|
function getCardBorderStyle(
|
||||||
|
enabled: boolean,
|
||||||
|
opacity: number,
|
||||||
|
borderColor: string
|
||||||
|
): React.CSSProperties {
|
||||||
|
if (!enabled) {
|
||||||
|
return { borderWidth: '0px', borderColor: 'transparent' };
|
||||||
|
}
|
||||||
|
if (opacity !== 100) {
|
||||||
|
return {
|
||||||
|
borderWidth: '2px',
|
||||||
|
borderColor: `color-mix(in oklch, ${borderColor} ${opacity}%, transparent)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { borderWidth: '2px' };
|
||||||
|
}
|
||||||
|
|
||||||
export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) {
|
export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) {
|
||||||
// Handle pipeline statuses by treating them like in_progress
|
// Handle pipeline statuses by treating them like in_progress
|
||||||
const status = data.status || 'backlog';
|
const status = data.status || 'backlog';
|
||||||
@@ -91,6 +109,28 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
|||||||
// Task is stopped if it's in_progress but not actively running
|
// Task is stopped if it's in_progress but not actively running
|
||||||
const isStopped = data.status === 'in_progress' && !data.isRunning;
|
const isStopped = data.status === 'in_progress' && !data.isRunning;
|
||||||
|
|
||||||
|
// Background/theme settings with defaults
|
||||||
|
const cardOpacity = data.cardOpacity ?? 100;
|
||||||
|
const glassmorphism = data.cardGlassmorphism ?? true;
|
||||||
|
const cardBorderEnabled = data.cardBorderEnabled ?? true;
|
||||||
|
const cardBorderOpacity = data.cardBorderOpacity ?? 100;
|
||||||
|
|
||||||
|
// Get the border color based on status and error state
|
||||||
|
const borderColor = data.error
|
||||||
|
? 'var(--status-error)'
|
||||||
|
: config.borderClass.includes('border-border')
|
||||||
|
? 'var(--border)'
|
||||||
|
: config.borderClass.includes('status-in-progress')
|
||||||
|
? 'var(--status-in-progress)'
|
||||||
|
: config.borderClass.includes('status-waiting')
|
||||||
|
? 'var(--status-waiting)'
|
||||||
|
: config.borderClass.includes('status-success')
|
||||||
|
? 'var(--status-success)'
|
||||||
|
: 'var(--border)';
|
||||||
|
|
||||||
|
// Get computed border style
|
||||||
|
const borderStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity, borderColor);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Target handle (left side - receives dependencies) */}
|
{/* Target handle (left side - receives dependencies) */}
|
||||||
@@ -109,22 +149,26 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-w-[240px] max-w-[280px] rounded-xl border-2 bg-card shadow-md',
|
'min-w-[240px] max-w-[280px] rounded-xl shadow-md relative',
|
||||||
'transition-all duration-300',
|
'transition-all duration-300',
|
||||||
config.borderClass,
|
|
||||||
selected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background',
|
selected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background',
|
||||||
data.isRunning && 'animate-pulse-subtle',
|
data.isRunning && 'animate-pulse-subtle',
|
||||||
data.error && 'border-[var(--status-error)]',
|
|
||||||
// Filter highlight states
|
// Filter highlight states
|
||||||
isMatched && 'graph-node-matched',
|
isMatched && 'graph-node-matched',
|
||||||
isHighlighted && !isMatched && 'graph-node-highlighted',
|
isHighlighted && !isMatched && 'graph-node-highlighted',
|
||||||
isDimmed && 'graph-node-dimmed'
|
isDimmed && 'graph-node-dimmed'
|
||||||
)}
|
)}
|
||||||
|
style={borderStyle}
|
||||||
>
|
>
|
||||||
|
{/* Background layer with opacity control - like KanbanCard */}
|
||||||
|
<div
|
||||||
|
className={cn('absolute inset-0 rounded-xl bg-card', glassmorphism && 'backdrop-blur-sm')}
|
||||||
|
style={{ opacity: cardOpacity / 100 }}
|
||||||
|
/>
|
||||||
{/* Header with status and actions */}
|
{/* Header with status and actions */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between px-3 py-2 rounded-t-[10px]',
|
'relative flex items-center justify-between px-3 py-2 rounded-t-[10px]',
|
||||||
config.bgClass
|
config.bgClass
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -301,7 +345,7 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-3 py-2">
|
<div className="relative px-3 py-2">
|
||||||
{/* Category */}
|
{/* Category */}
|
||||||
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wide">
|
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wide">
|
||||||
{data.category}
|
{data.category}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
|
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature, useAppStore } from '@/store/app-store';
|
||||||
|
import { themeOptions } from '@/config/theme-options';
|
||||||
import {
|
import {
|
||||||
TaskNode,
|
TaskNode,
|
||||||
DependencyEdge,
|
DependencyEdge,
|
||||||
@@ -47,6 +48,13 @@ const edgeTypes: any = {
|
|||||||
dependency: DependencyEdge,
|
dependency: DependencyEdge,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface BackgroundSettings {
|
||||||
|
cardOpacity: number;
|
||||||
|
cardGlassmorphism: boolean;
|
||||||
|
cardBorderEnabled: boolean;
|
||||||
|
cardBorderOpacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface GraphCanvasProps {
|
interface GraphCanvasProps {
|
||||||
features: Feature[];
|
features: Feature[];
|
||||||
runningAutoTasks: string[];
|
runningAutoTasks: string[];
|
||||||
@@ -56,6 +64,7 @@ interface GraphCanvasProps {
|
|||||||
nodeActionCallbacks?: NodeActionCallbacks;
|
nodeActionCallbacks?: NodeActionCallbacks;
|
||||||
onCreateDependency?: (sourceId: string, targetId: string) => Promise<boolean>;
|
onCreateDependency?: (sourceId: string, targetId: string) => Promise<boolean>;
|
||||||
backgroundStyle?: React.CSSProperties;
|
backgroundStyle?: React.CSSProperties;
|
||||||
|
backgroundSettings?: BackgroundSettings;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,11 +77,42 @@ function GraphCanvasInner({
|
|||||||
nodeActionCallbacks,
|
nodeActionCallbacks,
|
||||||
onCreateDependency,
|
onCreateDependency,
|
||||||
backgroundStyle,
|
backgroundStyle,
|
||||||
|
backgroundSettings,
|
||||||
className,
|
className,
|
||||||
}: GraphCanvasProps) {
|
}: GraphCanvasProps) {
|
||||||
const [isLocked, setIsLocked] = useState(false);
|
const [isLocked, setIsLocked] = useState(false);
|
||||||
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR');
|
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR');
|
||||||
|
|
||||||
|
// Determine React Flow color mode based on current theme
|
||||||
|
const effectiveTheme = useAppStore((state) => state.getEffectiveTheme());
|
||||||
|
const [systemColorMode, setSystemColorMode] = useState<'dark' | 'light'>(() => {
|
||||||
|
if (typeof window === 'undefined') return 'dark';
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (effectiveTheme !== 'system') return;
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const update = () => setSystemColorMode(mql.matches ? 'dark' : 'light');
|
||||||
|
update();
|
||||||
|
|
||||||
|
// Safari < 14 fallback
|
||||||
|
if (mql.addEventListener) {
|
||||||
|
mql.addEventListener('change', update);
|
||||||
|
return () => mql.removeEventListener('change', update);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line deprecation/deprecation
|
||||||
|
mql.addListener(update);
|
||||||
|
// eslint-disable-next-line deprecation/deprecation
|
||||||
|
return () => mql.removeListener(update);
|
||||||
|
}, [effectiveTheme]);
|
||||||
|
|
||||||
|
const themeOption = themeOptions.find((t) => t.value === effectiveTheme);
|
||||||
|
const colorMode =
|
||||||
|
effectiveTheme === 'system' ? systemColorMode : themeOption?.isDark ? 'dark' : 'light';
|
||||||
|
|
||||||
// Filter state (category, status, and negative toggle are local to graph view)
|
// Filter state (category, status, and negative toggle are local to graph view)
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||||
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
|
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
|
||||||
@@ -98,6 +138,7 @@ function GraphCanvasInner({
|
|||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
filterResult,
|
filterResult,
|
||||||
actionCallbacks: nodeActionCallbacks,
|
actionCallbacks: nodeActionCallbacks,
|
||||||
|
backgroundSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply layout
|
// Apply layout
|
||||||
@@ -234,6 +275,7 @@ function GraphCanvasInner({
|
|||||||
isValidConnection={isValidConnection}
|
isValidConnection={isValidConnection}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
|
colorMode={colorMode}
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.2 }}
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
minZoom={0.1}
|
minZoom={0.1}
|
||||||
@@ -256,7 +298,8 @@ function GraphCanvasInner({
|
|||||||
nodeStrokeWidth={3}
|
nodeStrokeWidth={3}
|
||||||
zoomable
|
zoomable
|
||||||
pannable
|
pannable
|
||||||
className="!bg-popover/90 !border-border rounded-lg shadow-lg"
|
className="border-border! rounded-lg shadow-lg"
|
||||||
|
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GraphControls
|
<GraphControls
|
||||||
@@ -281,7 +324,10 @@ function GraphCanvasInner({
|
|||||||
{/* Empty state when all nodes are filtered out */}
|
{/* Empty state when all nodes are filtered out */}
|
||||||
{filterResult.hasActiveFilter && filterResult.matchedNodeIds.size === 0 && (
|
{filterResult.hasActiveFilter && filterResult.matchedNodeIds.size === 0 && (
|
||||||
<Panel position="top-center" className="mt-20">
|
<Panel position="top-center" className="mt-20">
|
||||||
<div className="flex flex-col items-center gap-3 p-6 rounded-lg bg-popover/95 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
|
<div
|
||||||
|
className="flex flex-col items-center gap-3 p-6 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
|
||||||
|
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 95%, transparent)' }}
|
||||||
|
>
|
||||||
<SearchX className="w-10 h-10 text-muted-foreground" />
|
<SearchX className="w-10 h-10 text-muted-foreground" />
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm font-medium">No matching tasks</p>
|
<p className="text-sm font-medium">No matching tasks</p>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function GraphView({
|
|||||||
const { currentProject } = useAppStore();
|
const { currentProject } = useAppStore();
|
||||||
|
|
||||||
// Use the same background hook as the board view
|
// Use the same background hook as the board view
|
||||||
const { backgroundImageStyle } = useBoardBackground({ currentProject });
|
const { backgroundImageStyle, backgroundSettings } = useBoardBackground({ currentProject });
|
||||||
|
|
||||||
// Filter features by current worktree (same logic as board view)
|
// Filter features by current worktree (same logic as board view)
|
||||||
const filteredFeatures = useMemo(() => {
|
const filteredFeatures = useMemo(() => {
|
||||||
@@ -213,6 +213,7 @@ export function GraphView({
|
|||||||
nodeActionCallbacks={nodeActionCallbacks}
|
nodeActionCallbacks={nodeActionCallbacks}
|
||||||
onCreateDependency={handleCreateDependency}
|
onCreateDependency={handleCreateDependency}
|
||||||
backgroundStyle={backgroundImageStyle}
|
backgroundStyle={backgroundImageStyle}
|
||||||
|
backgroundSettings={backgroundSettings}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export interface TaskNodeData extends Feature {
|
|||||||
isMatched?: boolean;
|
isMatched?: boolean;
|
||||||
isHighlighted?: boolean;
|
isHighlighted?: boolean;
|
||||||
isDimmed?: boolean;
|
isDimmed?: boolean;
|
||||||
|
// Background/theme settings
|
||||||
|
cardOpacity?: number;
|
||||||
|
cardGlassmorphism?: boolean;
|
||||||
|
cardBorderEnabled?: boolean;
|
||||||
|
cardBorderOpacity?: number;
|
||||||
// Action callbacks
|
// Action callbacks
|
||||||
onViewLogs?: () => void;
|
onViewLogs?: () => void;
|
||||||
onViewDetails?: () => void;
|
onViewDetails?: () => void;
|
||||||
@@ -48,11 +53,19 @@ export interface NodeActionCallbacks {
|
|||||||
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BackgroundSettings {
|
||||||
|
cardOpacity: number;
|
||||||
|
cardGlassmorphism: boolean;
|
||||||
|
cardBorderEnabled: boolean;
|
||||||
|
cardBorderOpacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseGraphNodesProps {
|
interface UseGraphNodesProps {
|
||||||
features: Feature[];
|
features: Feature[];
|
||||||
runningAutoTasks: string[];
|
runningAutoTasks: string[];
|
||||||
filterResult?: GraphFilterResult;
|
filterResult?: GraphFilterResult;
|
||||||
actionCallbacks?: NodeActionCallbacks;
|
actionCallbacks?: NodeActionCallbacks;
|
||||||
|
backgroundSettings?: BackgroundSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,6 +77,7 @@ export function useGraphNodes({
|
|||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
filterResult,
|
filterResult,
|
||||||
actionCallbacks,
|
actionCallbacks,
|
||||||
|
backgroundSettings,
|
||||||
}: UseGraphNodesProps) {
|
}: UseGraphNodesProps) {
|
||||||
const { nodes, edges } = useMemo(() => {
|
const { nodes, edges } = useMemo(() => {
|
||||||
const nodeList: TaskNode[] = [];
|
const nodeList: TaskNode[] = [];
|
||||||
@@ -102,6 +116,11 @@ export function useGraphNodes({
|
|||||||
isMatched,
|
isMatched,
|
||||||
isHighlighted,
|
isHighlighted,
|
||||||
isDimmed,
|
isDimmed,
|
||||||
|
// Background/theme settings
|
||||||
|
cardOpacity: backgroundSettings?.cardOpacity,
|
||||||
|
cardGlassmorphism: backgroundSettings?.cardGlassmorphism,
|
||||||
|
cardBorderEnabled: backgroundSettings?.cardBorderEnabled,
|
||||||
|
cardBorderOpacity: backgroundSettings?.cardBorderOpacity,
|
||||||
// Action callbacks (bound to this feature's ID)
|
// Action callbacks (bound to this feature's ID)
|
||||||
onViewLogs: actionCallbacks?.onViewLogs
|
onViewLogs: actionCallbacks?.onViewLogs
|
||||||
? () => actionCallbacks.onViewLogs!(feature.id)
|
? () => actionCallbacks.onViewLogs!(feature.id)
|
||||||
@@ -163,7 +182,7 @@ export function useGraphNodes({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { nodes: nodeList, edges: edgeList };
|
return { nodes: nodeList, edges: edgeList };
|
||||||
}, [features, runningAutoTasks, filterResult, actionCallbacks]);
|
}, [features, runningAutoTasks, filterResult, actionCallbacks, backgroundSettings]);
|
||||||
|
|
||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,7 +298,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.light {
|
/* IMPORTANT:
|
||||||
|
* Theme classes like `.light` are applied to `:root` (html).
|
||||||
|
* Some third-party libraries (e.g. React Flow) also add `.light`/`.dark` classes
|
||||||
|
* to nested containers. If we define CSS variables on `.light` broadly, those
|
||||||
|
* nested containers will override the app theme and cause "white cards" in dark themes.
|
||||||
|
* Scoping to `:root.light` ensures only the root theme toggle controls variables.
|
||||||
|
*/
|
||||||
|
:root.light {
|
||||||
/* Explicit light mode - same as root but ensures it overrides any dark defaults */
|
/* Explicit light mode - same as root but ensures it overrides any dark defaults */
|
||||||
--background: oklch(1 0 0); /* White */
|
--background: oklch(1 0 0); /* White */
|
||||||
--background-50: oklch(1 0 0 / 0.5);
|
--background-50: oklch(1 0 0 / 0.5);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* Dark Theme */
|
/* Dark Theme */
|
||||||
|
|
||||||
.dark {
|
:root.dark {
|
||||||
/* Deep dark backgrounds - zinc-950 family */
|
/* Deep dark backgrounds - zinc-950 family */
|
||||||
--background: oklch(0.04 0 0); /* zinc-950 */
|
--background: oklch(0.04 0 0); /* zinc-950 */
|
||||||
--background-50: oklch(0.04 0 0 / 0.5); /* zinc-950/50 */
|
--background-50: oklch(0.04 0 0 / 0.5); /* zinc-950/50 */
|
||||||
|
|||||||
Reference in New Issue
Block a user