Skip to content

Commit ea73979

Browse files
committed
Polish Worktrunk sidebar headers
1 parent 4d22717 commit ea73979

1 file changed

Lines changed: 173 additions & 98 deletions

File tree

macos/Sources/Features/Worktrunk/WorktrunkSidebarView.swift

Lines changed: 173 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
808883
private enum SidebarFileURLDrop {
809884
static func loadURL(from providers: [NSItemProvider], completion: @escaping (URL?) -> Void) -> Bool {
810885
guard let provider = providers.first(where: {

0 commit comments

Comments
 (0)