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:
webdevcody
2026-01-10 15:41:35 -05:00
parent 555523df38
commit fa8ae149d3
15 changed files with 294 additions and 46 deletions

View File

@@ -31,7 +31,10 @@ export function GraphControls({
return (
<Panel position="bottom-left" className="flex flex-col gap-2">
<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 */}
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -110,7 +110,10 @@ export function GraphFilterControls({
return (
<Panel position="top-left" className="flex items-center gap-2">
<TooltipProvider delayDuration={200}>
<div className="flex items-center gap-2 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
<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 */}
<Popover>
<Tooltip>

View File

@@ -44,7 +44,10 @@ const legendItems = [
export function GraphLegend() {
return (
<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) => {
const Icon = item.icon;
return (

View File

@@ -75,6 +75,24 @@ const priorityConfig = {
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) {
// Handle pipeline statuses by treating them like in_progress
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
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 (
<>
{/* Target handle (left side - receives dependencies) */}
@@ -109,22 +149,26 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
<div
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',
config.borderClass,
selected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background',
data.isRunning && 'animate-pulse-subtle',
data.error && 'border-[var(--status-error)]',
// Filter highlight states
isMatched && 'graph-node-matched',
isHighlighted && !isMatched && 'graph-node-highlighted',
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 */}
<div
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
)}
>
@@ -301,7 +345,7 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
</div>
{/* Content */}
<div className="px-3 py-2">
<div className="relative px-3 py-2">
{/* Category */}
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wide">
{data.category}

View File

@@ -15,7 +15,8 @@ import {
} from '@xyflow/react';
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 {
TaskNode,
DependencyEdge,
@@ -47,6 +48,13 @@ const edgeTypes: any = {
dependency: DependencyEdge,
};
interface BackgroundSettings {
cardOpacity: number;
cardGlassmorphism: boolean;
cardBorderEnabled: boolean;
cardBorderOpacity: number;
}
interface GraphCanvasProps {
features: Feature[];
runningAutoTasks: string[];
@@ -56,6 +64,7 @@ interface GraphCanvasProps {
nodeActionCallbacks?: NodeActionCallbacks;
onCreateDependency?: (sourceId: string, targetId: string) => Promise<boolean>;
backgroundStyle?: React.CSSProperties;
backgroundSettings?: BackgroundSettings;
className?: string;
}
@@ -68,11 +77,42 @@ function GraphCanvasInner({
nodeActionCallbacks,
onCreateDependency,
backgroundStyle,
backgroundSettings,
className,
}: GraphCanvasProps) {
const [isLocked, setIsLocked] = useState(false);
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)
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
@@ -98,6 +138,7 @@ function GraphCanvasInner({
runningAutoTasks,
filterResult,
actionCallbacks: nodeActionCallbacks,
backgroundSettings,
});
// Apply layout
@@ -234,6 +275,7 @@ function GraphCanvasInner({
isValidConnection={isValidConnection}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
colorMode={colorMode}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
@@ -256,7 +298,8 @@ function GraphCanvasInner({
nodeStrokeWidth={3}
zoomable
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
@@ -281,7 +324,10 @@ function GraphCanvasInner({
{/* Empty state when all nodes are filtered out */}
{filterResult.hasActiveFilter && filterResult.matchedNodeIds.size === 0 && (
<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" />
<div className="text-center">
<p className="text-sm font-medium">No matching tasks</p>

View File

@@ -44,7 +44,7 @@ export function GraphView({
const { currentProject } = useAppStore();
// 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)
const filteredFeatures = useMemo(() => {
@@ -213,6 +213,7 @@ export function GraphView({
nodeActionCallbacks={nodeActionCallbacks}
onCreateDependency={handleCreateDependency}
backgroundStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
className="h-full"
/>
</div>

View File

@@ -18,6 +18,11 @@ export interface TaskNodeData extends Feature {
isMatched?: boolean;
isHighlighted?: boolean;
isDimmed?: boolean;
// Background/theme settings
cardOpacity?: number;
cardGlassmorphism?: boolean;
cardBorderEnabled?: boolean;
cardBorderOpacity?: number;
// Action callbacks
onViewLogs?: () => void;
onViewDetails?: () => void;
@@ -48,11 +53,19 @@ export interface NodeActionCallbacks {
onDeleteDependency?: (sourceId: string, targetId: string) => void;
}
interface BackgroundSettings {
cardOpacity: number;
cardGlassmorphism: boolean;
cardBorderEnabled: boolean;
cardBorderOpacity: number;
}
interface UseGraphNodesProps {
features: Feature[];
runningAutoTasks: string[];
filterResult?: GraphFilterResult;
actionCallbacks?: NodeActionCallbacks;
backgroundSettings?: BackgroundSettings;
}
/**
@@ -64,6 +77,7 @@ export function useGraphNodes({
runningAutoTasks,
filterResult,
actionCallbacks,
backgroundSettings,
}: UseGraphNodesProps) {
const { nodes, edges } = useMemo(() => {
const nodeList: TaskNode[] = [];
@@ -102,6 +116,11 @@ export function useGraphNodes({
isMatched,
isHighlighted,
isDimmed,
// Background/theme settings
cardOpacity: backgroundSettings?.cardOpacity,
cardGlassmorphism: backgroundSettings?.cardGlassmorphism,
cardBorderEnabled: backgroundSettings?.cardBorderEnabled,
cardBorderOpacity: backgroundSettings?.cardBorderOpacity,
// Action callbacks (bound to this feature's ID)
onViewLogs: actionCallbacks?.onViewLogs
? () => actionCallbacks.onViewLogs!(feature.id)
@@ -163,7 +182,7 @@ export function useGraphNodes({
});
return { nodes: nodeList, edges: edgeList };
}, [features, runningAutoTasks, filterResult, actionCallbacks]);
}, [features, runningAutoTasks, filterResult, actionCallbacks, backgroundSettings]);
return { nodes, edges };
}