@@ -130,6 +130,9 @@ struct WorktrunkSidebarView: View {
130130 onOpen: { openWorktree ( $0) }
131131 )
132132 }
133+ . onChange ( of: showRepoPicker) { isShowing in
134+ if isShowing { repoSearchText = " " }
135+ }
133136 . onChange ( of: sidebarState. selection) { newValue in
134137 var focusedWorktreePath : String ?
135138 if sidebarTabsEnabled {
@@ -280,19 +283,113 @@ struct WorktrunkSidebarView: View {
280283 }
281284 return result
282285 } ( )
283- let topWorktreePaths = Set ( worktreeTabs. compactMap ( \. worktreeRootPath) . map ( standardizedPath) )
286+ let activeTabs = sidebarTabItems ( from: worktreeTabs)
287+ let topWorktreePaths = Set ( activeTabs. map { standardizedPath ( $0. worktree. path) } )
288+ let activeTabWindowNumbers : [ String : Int ] = Dictionary (
289+ uniqueKeysWithValues: activeTabs. map { item in
290+ ( standardizedPath ( item. worktree. path) , item. tab. windowNumber)
291+ }
292+ )
293+ let lastActiveTabWindowNumber = activeTabs. last? . tab. windowNumber
294+ let hasVisibleWorktreeRows : Bool = {
295+ if store. sidebarListMode == . flatWorktrees {
296+ return snapshot. flatWorktrees. contains { wt in
297+ !topWorktreePaths. contains ( standardizedPath ( wt. path) )
298+ }
299+ } else {
300+ return !snapshot. repositories. isEmpty
301+ }
302+ } ( )
303+ let worktreesLoadingSpacer : CGFloat = {
304+ guard store. isRefreshing else { return 0 }
305+ guard !hasVisibleWorktreeRows else { return 0 }
306+ // OCR on the same-geometry screenshots measured the loading
307+ // header 21.19 px left of the resting position. User feedback
308+ // tuned that down slightly; 9.6 pt matches the current target.
309+ return 9.6
310+ } ( )
284311 return List ( selection: selection) {
285- Section {
286- if sidebarTabsEnabled {
287- sidebarTabsList ( snapshot: snapshot, tabs: worktreeTabs)
312+ if !activeTabs. isEmpty {
313+ sidebarHeaderRow ( " Active " , topPadding: - 4 )
314+ sidebarTabsList (
315+ snapshot: snapshot,
316+ shownTabs: activeTabs,
317+ windowNumberByWorktreePath: activeTabWindowNumbers
318+ )
319+ }
320+
321+ HStack ( spacing: 8 ) {
322+ HStack ( spacing: 8 ) {
323+ Text ( " Worktrees " )
324+ . font ( . caption. weight ( . semibold) )
325+ . foregroundStyle ( . secondary)
326+ . textCase ( nil )
288327 }
328+ . padding ( . leading, - 16 + worktreesLoadingSpacer)
289329
290- if store. sidebarListMode == . flatWorktrees {
291- flatWorktreeList ( snapshot: snapshot, excludingWorktreePaths: topWorktreePaths)
292- } else {
293- nestedRepoList ( snapshot: snapshot, excludingWorktreePaths: topWorktreePaths)
330+ Spacer ( minLength: 0 )
331+
332+ Button {
333+ presentCreateWorktree ( from: snapshot)
334+ } label: {
335+ Image ( systemName: " plus " )
336+ . foregroundStyle ( . secondary)
337+ }
338+ . buttonStyle ( . plain)
339+ . disabled ( snapshot. repositories. isEmpty)
340+ . help ( snapshot. repositories. isEmpty ? " Add a repository first " : " Create worktree " )
341+ . padding ( . trailing, 8 )
342+ . popover ( isPresented: $showRepoPicker) {
343+ RepoPickerPopover (
344+ repositories: store. sidebarSnapshot. repositories,
345+ searchText: $repoSearchText
346+ ) { repo in
347+ showRepoPicker = false
348+ createSheetRepo = repo
349+ }
350+ }
351+ }
352+ . frame ( maxWidth: . infinity, alignment: . leading)
353+ . contentShape ( Rectangle ( ) )
354+ . padding ( . top, activeTabs. isEmpty ? - 2 : 4 )
355+ . listRowInsets ( EdgeInsets ( top: 0 , leading: 8 , bottom: 0 , trailing: 0 ) )
356+ . listRowSeparator ( . hidden)
357+ . listRowBackground ( Color . clear)
358+ . overlay ( alignment: . top) {
359+ if sidebarTabsEndDropTarget {
360+ SidebarInsertionIndicatorLine ( )
361+ }
362+ }
363+ . onDrop ( of: [ UTType . fileURL. identifier] , isTargeted: Binding (
364+ get: { sidebarTabsEndDropTarget } ,
365+ set: { targeted in
366+ DispatchQueue . main. async {
367+ sidebarTabsEndDropTarget = targeted
368+ }
369+ }
370+ ) ) { providers in
371+ guard let lastActiveTabWindowNumber else { return false }
372+ return SidebarFileURLDrop . loadURL ( from: providers) { url in
373+ guard let url else { return }
374+ let key = URL ( fileURLWithPath: url. path) . standardizedFileURL. path
375+ guard let moving = activeTabWindowNumbers [ key] else { return }
376+ guard moving != lastActiveTabWindowNumber else { return }
377+
378+ let scrollY = sidebarScrollPreserver. captureScrollY ( )
379+ moveNativeTabAfter ( moving, lastActiveTabWindowNumber)
380+ if let scrollY {
381+ DispatchQueue . main. async {
382+ sidebarScrollPreserver. restoreScrollY ( scrollY)
383+ }
384+ }
294385 }
295386 }
387+
388+ if store. sidebarListMode == . flatWorktrees {
389+ flatWorktreeList ( snapshot: snapshot, excludingWorktreePaths: topWorktreePaths)
390+ } else {
391+ nestedRepoList ( snapshot: snapshot, excludingWorktreePaths: topWorktreePaths)
392+ }
296393 }
297394 . background ( SidebarListScrollFinder ( preserver: sidebarScrollPreserver) )
298395 . id ( store. sidebarListMode. rawValue + ( sidebarTabsEnabled ? " .sidebarTabs " : " " ) )
@@ -320,22 +417,20 @@ struct WorktrunkSidebarView: View {
320417 return tabRoots. intersection ( store. sidebarWorktreePaths)
321418 }
322419
420+ private func sidebarTabItems( from tabs: [ WorktrunkOpenTabsModel . Tab ] ) -> [ SidebarTabItem ] {
421+ tabs. compactMap { tab in
422+ guard let root = tab. worktreeRootPath else { return nil }
423+ guard let worktree = findWorktree ( forWorktreeRootPath: root) else { return nil }
424+ return SidebarTabItem ( tab: tab, worktree: worktree)
425+ }
426+ }
427+
323428 @ViewBuilder
324429 private func sidebarTabsList(
325430 snapshot: WorktrunkStore . SidebarSnapshot ,
326- tabs: [ WorktrunkOpenTabsModel . Tab ]
431+ shownTabs: [ SidebarTabItem ] ,
432+ windowNumberByWorktreePath: [ String : Int ]
327433 ) -> some View {
328- let shownTabs : [ ( tab: WorktrunkOpenTabsModel . Tab , worktree: WorktrunkStore . Worktree ) ] = tabs. compactMap { tab in
329- guard let root = tab. worktreeRootPath else { return nil }
330- guard let wt = findWorktree ( forWorktreeRootPath: root) else { return nil }
331- return ( tab, wt)
332- }
333- let windowNumberByWorktreePath : [ String : Int ] = Dictionary (
334- uniqueKeysWithValues: shownTabs. map { item in
335- let key = URL ( fileURLWithPath: item. worktree. path) . standardizedFileURL. path
336- return ( key, item. tab. windowNumber)
337- }
338- )
339434 let alwaysVisibleWorktreePaths = Set ( windowNumberByWorktreePath. keys)
340435 let moveBeforePreservingScroll : ( Int , Int ) -> Void = { moving, target in
341436 let scrollY = sidebarScrollPreserver. captureScrollY ( )
@@ -380,37 +475,6 @@ struct WorktrunkSidebarView: View {
380475 windowNumberByWorktreePath: windowNumberByWorktreePath
381476 )
382477 }
383-
384- if let last = shownTabs. last? . tab {
385- Rectangle ( )
386- . fill ( Color . clear)
387- . frame ( maxWidth: . infinity)
388- . frame ( height: 1 )
389- . contentShape ( Rectangle ( ) )
390- . listRowInsets ( EdgeInsets ( top: 0 , leading: 0 , bottom: 0 , trailing: 0 ) )
391- . listRowSeparator ( . hidden)
392- . overlay ( alignment: . center) {
393- if sidebarTabsEndDropTarget {
394- SidebarInsertionIndicatorLine ( )
395- }
396- }
397- . onDrop ( of: [ UTType . fileURL. identifier] , isTargeted: Binding (
398- get: { sidebarTabsEndDropTarget } ,
399- set: { targeted in
400- DispatchQueue . main. async {
401- sidebarTabsEndDropTarget = targeted
402- }
403- }
404- ) ) { providers in
405- return SidebarFileURLDrop . loadURL ( from: providers) { url in
406- guard let url else { return }
407- let key = URL ( fileURLWithPath: url. path) . standardizedFileURL. path
408- guard let moving = windowNumberByWorktreePath [ key] else { return }
409- guard moving != last. windowNumber else { return }
410- moveAfterPreservingScroll ( moving, last. windowNumber)
411- }
412- }
413- }
414478 }
415479
416480 @ViewBuilder
@@ -437,20 +501,6 @@ struct WorktrunkSidebarView: View {
437501 }
438502 )
439503 ) {
440- HStack ( spacing: 8 ) {
441- Image ( systemName: " plus.circle " )
442- . foregroundStyle ( . secondary)
443- Text ( " New worktree… " )
444- . foregroundStyle ( . secondary)
445- Spacer ( )
446- }
447- . padding ( . bottom, 2 )
448- . contentShape ( Rectangle ( ) )
449- . onTapGesture {
450- createSheetRepo = repo
451- }
452- . help ( " Create worktree " )
453-
454504 let worktrees = ( snapshot. worktreesByRepositoryID [ repo. id] ?? [ ] ) . filter { wt in
455505 !excludingWorktreePaths. contains ( standardizedPath ( wt. path) )
456506 }
@@ -505,41 +555,11 @@ struct WorktrunkSidebarView: View {
505555 !excludingWorktreePaths. contains ( standardizedPath ( wt. path) )
506556 }
507557
508- if !snapshot. repositories. isEmpty {
509- HStack ( spacing: 8 ) {
510- Image ( systemName: " plus.circle " )
511- . foregroundStyle ( . secondary)
512- Text ( " New worktree… " )
558+ if worktrees. isEmpty {
559+ if !store. isRefreshing {
560+ Text ( " No worktrees " )
513561 . foregroundStyle ( . secondary)
514- Spacer ( )
515562 }
516- . padding ( . bottom, 2 )
517- . contentShape ( Rectangle ( ) )
518- . onTapGesture {
519- if snapshot. repositories. count == 1 , let repo = snapshot. repositories. first {
520- createSheetRepo = repo
521- } else {
522- showRepoPicker = true
523- }
524- }
525- . help ( " Create worktree " )
526- . popover ( isPresented: $showRepoPicker) {
527- RepoPickerPopover (
528- repositories: snapshot. repositories,
529- searchText: $repoSearchText
530- ) { repo in
531- showRepoPicker = false
532- createSheetRepo = repo
533- }
534- }
535- . onChange ( of: showRepoPicker) { isShowing in
536- if isShowing { repoSearchText = " " }
537- }
538- }
539-
540- if worktrees. isEmpty {
541- Text ( " No worktrees " )
542- . foregroundStyle ( . secondary)
543563 } else {
544564 ForEach ( worktrees) { wt in
545565 worktreeDisclosureGroup (
@@ -552,6 +572,56 @@ struct WorktrunkSidebarView: View {
552572 }
553573 }
554574
575+ @ViewBuilder
576+ private func sidebarHeaderRow(
577+ _ title: String ,
578+ topPadding: CGFloat = 0 ,
579+ bottomPadding: CGFloat = 0 ,
580+ leadingShift: CGFloat = - 16 ,
581+ addAction: ( ( ) -> Void ) ? = nil ,
582+ addDisabled: Bool = false
583+ ) -> some View {
584+ HStack ( spacing: 8 ) {
585+ HStack ( spacing: 8 ) {
586+ Text ( title)
587+ . font ( . caption. weight ( . semibold) )
588+ . foregroundStyle ( . secondary)
589+ . textCase ( nil )
590+ }
591+ . padding ( . leading, leadingShift)
592+
593+ Spacer ( minLength: 0 )
594+
595+ if let addAction {
596+ Button ( action: addAction) {
597+ Image ( systemName: " plus " )
598+ . foregroundStyle ( . secondary)
599+ }
600+ . buttonStyle ( . plain)
601+ . disabled ( addDisabled)
602+ . help ( addDisabled ? " Add a repository first " : " Create worktree " )
603+ . padding ( . trailing, 8 )
604+ }
605+ }
606+ . frame ( maxWidth: . infinity, alignment: . leading)
607+ . contentShape ( Rectangle ( ) )
608+ . padding ( . top, topPadding)
609+ . padding ( . bottom, bottomPadding)
610+ . listRowInsets ( EdgeInsets ( top: 0 , leading: 8 , bottom: 0 , trailing: 0 ) )
611+ . listRowSeparator ( . hidden)
612+ . listRowBackground ( Color . clear)
613+ }
614+
615+ private func presentCreateWorktree( from snapshot: WorktrunkStore . SidebarSnapshot ) {
616+ guard !snapshot. repositories. isEmpty else { return }
617+
618+ if snapshot. repositories. count == 1 , let repo = snapshot. repositories. first {
619+ createSheetRepo = repo
620+ } else {
621+ showRepoPicker = true
622+ }
623+ }
624+
555625 private func toggleSidebarListMode( ) {
556626 if store. sidebarListMode == . flatWorktrees {
557627 store. sidebarListMode = . nestedByRepo
@@ -805,6 +875,11 @@ struct WorktrunkSidebarView: View {
805875 }
806876}
807877
878+ private struct SidebarTabItem {
879+ let tab : WorktrunkOpenTabsModel . Tab
880+ let worktree : WorktrunkStore . Worktree
881+ }
882+
808883private enum SidebarFileURLDrop {
809884 static func loadURL( from providers: [ NSItemProvider ] , completion: @escaping ( URL ? ) -> Void ) -> Bool {
810885 guard let provider = providers. first ( where: {
0 commit comments