Skip to content

Commit 4d4459c

Browse files
committed
feat(webui): drag-and-drop project reordering in sidebar
Replace alphabetical/recent sort buttons with a manual reorder mode. Toggle reorder via ArrowUpDown icon (right side of action bar). When active: projects collapse, chevrons become grip handles, drag to reorder, order persisted to localStorage. Also removes FolderGit2 icon from project rows — the expand/collapse chevron is sufficient visual affordance. Confidence: high Scope-risk: narrow Tested: tsc clean, lint clean, 613 tests pass
1 parent 89f5dca commit 4d4459c

File tree

1 file changed

+223
-42
lines changed

1 file changed

+223
-42
lines changed

client/src/components/layout/Sidebar.tsx

Lines changed: 223 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,33 @@ import {
2828
ContextMenuSeparator,
2929
ContextMenuTrigger,
3030
} from '@/components/ui/context-menu';
31+
import {
32+
DndContext,
33+
closestCenter,
34+
KeyboardSensor,
35+
PointerSensor,
36+
useSensor,
37+
useSensors,
38+
} from '@dnd-kit/core';
39+
import type {DragEndEvent} from '@dnd-kit/core';
40+
import {
41+
SortableContext,
42+
sortableKeyboardCoordinates,
43+
useSortable,
44+
verticalListSortingStrategy,
45+
} from '@dnd-kit/sortable';
46+
import {CSS} from '@dnd-kit/utilities';
3147
import {
3248
AlertTriangle,
49+
ArrowUpDown,
3350
ChevronsRight,
3451
ChevronDown,
3552
ChevronRight,
3653
FolderCog,
3754
FolderGit2,
3855
FolderPlus,
3956
GitBranch,
57+
GripVertical,
4058
ListTodo,
4159
MessageSquare,
4260
MoreVertical,
@@ -56,6 +74,36 @@ const areSetsEqual = (a: Set<string>, b: Set<string>) => {
5674
return true;
5775
};
5876

77+
import type {DraggableAttributes} from '@dnd-kit/core';
78+
import type {SyntheticListenerMap} from '@dnd-kit/core/dist/hooks/utilities';
79+
80+
interface SortableProps {
81+
attributes: DraggableAttributes;
82+
listeners: SyntheticListenerMap | undefined;
83+
}
84+
85+
function SortableProjectWrapper({
86+
id,
87+
children,
88+
}: {
89+
id: string;
90+
children: (props: {sortableProps: SortableProps; style: React.CSSProperties}) => React.ReactNode;
91+
}) {
92+
const {attributes, listeners, setNodeRef, transform, transition, isDragging} =
93+
useSortable({id});
94+
95+
const style: React.CSSProperties = {
96+
transform: CSS.Transform.toString(transform),
97+
transition,
98+
opacity: isDragging ? 0.5 : 1,
99+
};
100+
101+
return (
102+
<div ref={setNodeRef} style={style}>
103+
{children({sortableProps: {attributes, listeners}, style})}
104+
</div>
105+
);
106+
}
59107

60108
export function Sidebar() {
61109
const {
@@ -463,10 +511,93 @@ export function Sidebar() {
463511
});
464512
};
465513

466-
// Data passthrough (filtering removed for now)
514+
// Toggle project expansion
515+
const toggleProject = (path: string) => {
516+
setExpandedProjects(prev => {
517+
const next = new Set(prev);
518+
if (next.has(path)) {
519+
next.delete(path);
520+
} else {
521+
next.add(path);
522+
}
523+
return next;
524+
});
525+
};
526+
527+
// Reorder mode
528+
const [reorderMode, setReorderMode] = useState(false);
529+
const savedExpandedRef = useRef<Set<string> | null>(null);
530+
531+
// Custom project order (persisted to localStorage)
532+
const [projectOrder, setProjectOrder] = useState<string[]>(() => {
533+
try {
534+
const saved = localStorage.getItem('argusdev-project-order');
535+
return saved ? (JSON.parse(saved) as string[]) : [];
536+
} catch {
537+
return [];
538+
}
539+
});
540+
useEffect(() => {
541+
if (projectOrder.length > 0) {
542+
localStorage.setItem(
543+
'argusdev-project-order',
544+
JSON.stringify(projectOrder),
545+
);
546+
}
547+
}, [projectOrder]);
548+
549+
const toggleReorderMode = () => {
550+
if (!reorderMode) {
551+
savedExpandedRef.current = new Set(expandedProjects);
552+
setExpandedProjects(new Set());
553+
} else {
554+
if (savedExpandedRef.current) {
555+
setExpandedProjects(savedExpandedRef.current);
556+
savedExpandedRef.current = null;
557+
}
558+
}
559+
setReorderMode(prev => !prev);
560+
};
561+
562+
// dnd-kit sensors
563+
const sensors = useSensors(
564+
useSensor(PointerSensor, {activationConstraint: {distance: 5}}),
565+
useSensor(KeyboardSensor, {
566+
coordinateGetter: sortableKeyboardCoordinates,
567+
}),
568+
);
569+
570+
const handleDragEnd = (event: DragEndEvent) => {
571+
const {active, over} = event;
572+
if (!over || active.id === over.id) return;
573+
const ordered = filteredData.projects;
574+
const oldIndex = ordered.findIndex(p => p.path === active.id);
575+
const newIndex = ordered.findIndex(p => p.path === over.id);
576+
if (oldIndex === -1 || newIndex === -1) return;
577+
const reordered = [...ordered];
578+
const [moved] = reordered.splice(oldIndex, 1);
579+
reordered.splice(newIndex, 0, moved!);
580+
setProjectOrder(reordered.map(p => p.path));
581+
};
582+
583+
// Apply custom order to projects
467584
const filteredData = useMemo(() => {
468-
return {projects, worktrees, sessions};
469-
}, [projects, worktrees, sessions]);
585+
let ordered = [...projects];
586+
if (projectOrder.length > 0) {
587+
const orderMap = new Map(projectOrder.map((path, i) => [path, i]));
588+
ordered.sort((a, b) => {
589+
const ai = orderMap.get(a.path) ?? Number.MAX_SAFE_INTEGER;
590+
const bi = orderMap.get(b.path) ?? Number.MAX_SAFE_INTEGER;
591+
if (ai !== bi) return ai - bi;
592+
return a.name.localeCompare(b.name, undefined, {sensitivity: 'base'});
593+
});
594+
} else {
595+
ordered.sort((a, b) =>
596+
a.name.localeCompare(b.name, undefined, {sensitivity: 'base'}),
597+
);
598+
}
599+
return {projects: ordered, worktrees, sessions};
600+
}, [projects, worktrees, sessions, projectOrder]);
470601

471602
// Helper to get sessions for a worktree
472603
const getSessionsForWorktree = (worktreePath: string) => {
@@ -551,7 +682,7 @@ export function Sidebar() {
551682
return (
552683
<aside className="flex h-full w-56 flex-col border-r border-border bg-sidebar lg:w-64 overflow-hidden">
553684
{/* Action bar */}
554-
<div className="flex h-9 items-center gap-1 px-2 border-b border-border">
685+
<div className="flex h-8 items-center gap-1 px-2 border-b border-border">
555686
<DropdownMenu>
556687
<DropdownMenuTrigger asChild>
557688
<Button
@@ -589,6 +720,21 @@ export function Sidebar() {
589720

590721
<div className="flex-1" />
591722

723+
{projects.length > 1 && (
724+
<Button
725+
variant="ghost"
726+
size="icon"
727+
className={cn(
728+
'h-6 w-6 shrink-0',
729+
reorderMode && 'bg-muted text-foreground',
730+
)}
731+
onClick={toggleReorderMode}
732+
title={reorderMode ? 'Done reordering' : 'Reorder projects'}
733+
>
734+
<ArrowUpDown className="h-3.5 w-3.5" />
735+
</Button>
736+
)}
737+
592738
<Button
593739
variant="ghost"
594740
size="icon"
@@ -603,12 +749,13 @@ export function Sidebar() {
603749
{/* Tree content */}
604750
<ScrollArea className="flex-1">
605751
<div className="py-1">
606-
{filteredData.projects.map((project, projectIndex) => {
752+
{(() => {
753+
const projectList = filteredData.projects.map((project, projectIndex) => {
607754
const projectWorktrees = getWorktreesForProject(project.path);
608755
const isCurrentProject = currentProject?.path === project.path;
609756
const isInvalid = project.isValid === false;
610757

611-
return (
758+
const renderProject = (sortableProps?: SortableProps) => (
612759
<div
613760
key={project.path}
614761
className={cn(projectIndex > 0 && 'mt-2')}
@@ -618,18 +765,26 @@ export function Sidebar() {
618765
<ContextMenuTrigger asChild>
619766
<div
620767
className={cn(
621-
'group flex w-full min-w-0 items-center gap-2 px-2 py-2 text-sm',
768+
'group flex w-full min-w-0 items-center gap-2 px-2 py-2 text-sm cursor-pointer',
622769
'bg-muted/50 hover:bg-muted transition-colors',
623770
isCurrentProject && 'bg-muted',
624771
isMobile && 'min-h-[44px]',
625772
)}
773+
onClick={() => {
774+
if (!reorderMode && renamingProject !== project.path) {
775+
toggleProject(project.path);
776+
}
777+
}}
626778
>
627-
<FolderGit2
628-
className={cn(
629-
'h-4 w-4 shrink-0 transition-colors',
630-
isInvalid ? 'text-yellow-600' : 'text-primary',
631-
)}
632-
/>
779+
{reorderMode ? (
780+
<span className="touch-none cursor-grab" {...sortableProps?.attributes} {...sortableProps?.listeners}>
781+
<GripVertical className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
782+
</span>
783+
) : expandedProjects.has(project.path) ? (
784+
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
785+
) : (
786+
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
787+
)}
633788
{renamingProject === project.path ? (
634789
<Input
635790
ref={projectRenameInputRef}
@@ -683,19 +838,21 @@ export function Sidebar() {
683838
<DropdownMenuSeparator />
684839
</>
685840
)}
686-
<DropdownMenuItem
687-
onClick={async () => {
688-
const selected = await ensureProjectSelected(
689-
project.path,
690-
);
691-
if (!selected) return;
692-
openTaskBoard();
693-
}}
694-
disabled={isInvalid}
695-
>
696-
<ListTodo className="h-3.5 w-3.5 mr-2" />
697-
Task Board
698-
</DropdownMenuItem>
841+
{project.tdEnabled && (
842+
<DropdownMenuItem
843+
onClick={async () => {
844+
const selected = await ensureProjectSelected(
845+
project.path,
846+
);
847+
if (!selected) return;
848+
openTaskBoard();
849+
}}
850+
disabled={isInvalid}
851+
>
852+
<ListTodo className="h-3.5 w-3.5 mr-2" />
853+
Task Board
854+
</DropdownMenuItem>
855+
)}
699856
<DropdownMenuItem
700857
onClick={async () => {
701858
const selected = await ensureProjectSelected(
@@ -767,19 +924,21 @@ export function Sidebar() {
767924
<ContextMenuSeparator />
768925
</>
769926
)}
770-
<ContextMenuItem
771-
onClick={async () => {
772-
const selected = await ensureProjectSelected(
773-
project.path,
774-
);
775-
if (!selected) return;
776-
openTaskBoard();
777-
}}
778-
disabled={isInvalid}
779-
>
780-
<ListTodo className="h-3.5 w-3.5 mr-2" />
781-
Task Board
782-
</ContextMenuItem>
927+
{project.tdEnabled && (
928+
<ContextMenuItem
929+
onClick={async () => {
930+
const selected = await ensureProjectSelected(
931+
project.path,
932+
);
933+
if (!selected) return;
934+
openTaskBoard();
935+
}}
936+
disabled={isInvalid}
937+
>
938+
<ListTodo className="h-3.5 w-3.5 mr-2" />
939+
Task Board
940+
</ContextMenuItem>
941+
)}
783942
<ContextMenuItem
784943
onClick={async () => {
785944
const selected = await ensureProjectSelected(
@@ -839,7 +998,7 @@ export function Sidebar() {
839998
</ContextMenu>
840999

8411000
{/* Worktrees */}
842-
<div className="py-1 min-w-0">
1001+
{expandedProjects.has(project.path) && <div className="py-1 min-w-0">
8431002
{projectWorktrees.length === 0 ? (
8441003
<div className="px-3 py-2 text-xs text-muted-foreground italic">
8451004
No worktrees
@@ -1131,10 +1290,32 @@ export function Sidebar() {
11311290
);
11321291
})
11331292
)}
1134-
</div>
1293+
</div>}
11351294
</div>
11361295
);
1137-
})}
1296+
1297+
if (reorderMode) {
1298+
return (
1299+
<SortableProjectWrapper key={project.path} id={project.path}>
1300+
{({sortableProps}) => renderProject(sortableProps)}
1301+
</SortableProjectWrapper>
1302+
);
1303+
}
1304+
1305+
return renderProject();
1306+
});
1307+
1308+
if (reorderMode) {
1309+
return (
1310+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
1311+
<SortableContext items={filteredData.projects.map(p => p.path)} strategy={verticalListSortingStrategy}>
1312+
{projectList}
1313+
</SortableContext>
1314+
</DndContext>
1315+
);
1316+
}
1317+
return projectList;
1318+
})()}
11381319

11391320
{/* Show message if no projects */}
11401321
{filteredData.projects.length === 0 && (

0 commit comments

Comments
 (0)