@@ -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' ;
3147import {
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
60108export 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