Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 173 additions & 98 deletions macos/Sources/Features/Worktrunk/WorktrunkSidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ struct WorktrunkSidebarView: View {
onOpen: { openWorktree($0) }
)
}
.onChange(of: showRepoPicker) { isShowing in
if isShowing { repoSearchText = "" }
}
.onChange(of: sidebarState.selection) { newValue in
var focusedWorktreePath: String?
if sidebarTabsEnabled {
Expand Down Expand Up @@ -280,19 +283,113 @@ struct WorktrunkSidebarView: View {
}
return result
}()
let topWorktreePaths = Set(worktreeTabs.compactMap(\.worktreeRootPath).map(standardizedPath))
let activeTabs = sidebarTabItems(from: worktreeTabs)
let topWorktreePaths = Set(activeTabs.map { standardizedPath($0.worktree.path) })
let activeTabWindowNumbers: [String: Int] = Dictionary(
uniqueKeysWithValues: activeTabs.map { item in
(standardizedPath(item.worktree.path), item.tab.windowNumber)
}
)
let lastActiveTabWindowNumber = activeTabs.last?.tab.windowNumber
let hasVisibleWorktreeRows: Bool = {
if store.sidebarListMode == .flatWorktrees {
return snapshot.flatWorktrees.contains { wt in
!topWorktreePaths.contains(standardizedPath(wt.path))
}
} else {
return !snapshot.repositories.isEmpty
}
}()
let worktreesLoadingSpacer: CGFloat = {
guard store.isRefreshing else { return 0 }
guard !hasVisibleWorktreeRows else { return 0 }
// OCR on the same-geometry screenshots measured the loading
// header 21.19 px left of the resting position. User feedback
// tuned that down slightly; 9.6 pt matches the current target.
return 9.6
}()
return List(selection: selection) {
Section {
if sidebarTabsEnabled {
sidebarTabsList(snapshot: snapshot, tabs: worktreeTabs)
if !activeTabs.isEmpty {
sidebarHeaderRow("Active", topPadding: -4)
sidebarTabsList(
snapshot: snapshot,
shownTabs: activeTabs,
windowNumberByWorktreePath: activeTabWindowNumbers
)
}

HStack(spacing: 8) {
HStack(spacing: 8) {
Text("Worktrees")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(nil)
}
.padding(.leading, -16 + worktreesLoadingSpacer)

if store.sidebarListMode == .flatWorktrees {
flatWorktreeList(snapshot: snapshot, excludingWorktreePaths: topWorktreePaths)
} else {
nestedRepoList(snapshot: snapshot, excludingWorktreePaths: topWorktreePaths)
Spacer(minLength: 0)

Button {
presentCreateWorktree(from: snapshot)
} label: {
Image(systemName: "plus")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.disabled(snapshot.repositories.isEmpty)
.help(snapshot.repositories.isEmpty ? "Add a repository first" : "Create worktree")
.padding(.trailing, 8)
.popover(isPresented: $showRepoPicker) {
RepoPickerPopover(
repositories: store.sidebarSnapshot.repositories,
searchText: $repoSearchText
) { repo in
showRepoPicker = false
createSheetRepo = repo
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.padding(.top, activeTabs.isEmpty ? -2 : 4)
.listRowInsets(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 0))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.overlay(alignment: .top) {
if sidebarTabsEndDropTarget {
SidebarInsertionIndicatorLine()
}
}
.onDrop(of: [UTType.fileURL.identifier], isTargeted: Binding(
get: { sidebarTabsEndDropTarget },
set: { targeted in
DispatchQueue.main.async {
sidebarTabsEndDropTarget = targeted
}
}
)) { providers in
guard let lastActiveTabWindowNumber else { return false }
return SidebarFileURLDrop.loadURL(from: providers) { url in
guard let url else { return }
let key = URL(fileURLWithPath: url.path).standardizedFileURL.path
guard let moving = activeTabWindowNumbers[key] else { return }
guard moving != lastActiveTabWindowNumber else { return }

let scrollY = sidebarScrollPreserver.captureScrollY()
moveNativeTabAfter(moving, lastActiveTabWindowNumber)
if let scrollY {
DispatchQueue.main.async {
sidebarScrollPreserver.restoreScrollY(scrollY)
}
}
}
}

if store.sidebarListMode == .flatWorktrees {
flatWorktreeList(snapshot: snapshot, excludingWorktreePaths: topWorktreePaths)
} else {
nestedRepoList(snapshot: snapshot, excludingWorktreePaths: topWorktreePaths)
}
}
.background(SidebarListScrollFinder(preserver: sidebarScrollPreserver))
.id(store.sidebarListMode.rawValue + (sidebarTabsEnabled ? ".sidebarTabs" : ""))
Expand Down Expand Up @@ -320,22 +417,20 @@ struct WorktrunkSidebarView: View {
return tabRoots.intersection(store.sidebarWorktreePaths)
}

private func sidebarTabItems(from tabs: [WorktrunkOpenTabsModel.Tab]) -> [SidebarTabItem] {
tabs.compactMap { tab in
guard let root = tab.worktreeRootPath else { return nil }
guard let worktree = findWorktree(forWorktreeRootPath: root) else { return nil }
return SidebarTabItem(tab: tab, worktree: worktree)
}
}

@ViewBuilder
private func sidebarTabsList(
snapshot: WorktrunkStore.SidebarSnapshot,
tabs: [WorktrunkOpenTabsModel.Tab]
shownTabs: [SidebarTabItem],
windowNumberByWorktreePath: [String: Int]
) -> some View {
let shownTabs: [(tab: WorktrunkOpenTabsModel.Tab, worktree: WorktrunkStore.Worktree)] = tabs.compactMap { tab in
guard let root = tab.worktreeRootPath else { return nil }
guard let wt = findWorktree(forWorktreeRootPath: root) else { return nil }
return (tab, wt)
}
let windowNumberByWorktreePath: [String: Int] = Dictionary(
uniqueKeysWithValues: shownTabs.map { item in
let key = URL(fileURLWithPath: item.worktree.path).standardizedFileURL.path
return (key, item.tab.windowNumber)
}
)
let alwaysVisibleWorktreePaths = Set(windowNumberByWorktreePath.keys)
let moveBeforePreservingScroll: (Int, Int) -> Void = { moving, target in
let scrollY = sidebarScrollPreserver.captureScrollY()
Expand Down Expand Up @@ -380,37 +475,6 @@ struct WorktrunkSidebarView: View {
windowNumberByWorktreePath: windowNumberByWorktreePath
)
}

if let last = shownTabs.last?.tab {
Rectangle()
.fill(Color.clear)
.frame(maxWidth: .infinity)
.frame(height: 1)
.contentShape(Rectangle())
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.listRowSeparator(.hidden)
.overlay(alignment: .center) {
if sidebarTabsEndDropTarget {
SidebarInsertionIndicatorLine()
}
}
.onDrop(of: [UTType.fileURL.identifier], isTargeted: Binding(
get: { sidebarTabsEndDropTarget },
set: { targeted in
DispatchQueue.main.async {
sidebarTabsEndDropTarget = targeted
}
}
)) { providers in
return SidebarFileURLDrop.loadURL(from: providers) { url in
guard let url else { return }
let key = URL(fileURLWithPath: url.path).standardizedFileURL.path
guard let moving = windowNumberByWorktreePath[key] else { return }
guard moving != last.windowNumber else { return }
moveAfterPreservingScroll(moving, last.windowNumber)
}
}
}
}

@ViewBuilder
Expand All @@ -437,20 +501,6 @@ struct WorktrunkSidebarView: View {
}
)
) {
HStack(spacing: 8) {
Image(systemName: "plus.circle")
.foregroundStyle(.secondary)
Text("New worktree…")
.foregroundStyle(.secondary)
Spacer()
}
.padding(.bottom, 2)
.contentShape(Rectangle())
.onTapGesture {
createSheetRepo = repo
}
.help("Create worktree")

let worktrees = (snapshot.worktreesByRepositoryID[repo.id] ?? []).filter { wt in
!excludingWorktreePaths.contains(standardizedPath(wt.path))
}
Expand Down Expand Up @@ -505,41 +555,11 @@ struct WorktrunkSidebarView: View {
!excludingWorktreePaths.contains(standardizedPath(wt.path))
}

if !snapshot.repositories.isEmpty {
HStack(spacing: 8) {
Image(systemName: "plus.circle")
.foregroundStyle(.secondary)
Text("New worktree…")
if worktrees.isEmpty {
if !store.isRefreshing {
Text("No worktrees")
.foregroundStyle(.secondary)
Spacer()
}
.padding(.bottom, 2)
.contentShape(Rectangle())
.onTapGesture {
if snapshot.repositories.count == 1, let repo = snapshot.repositories.first {
createSheetRepo = repo
} else {
showRepoPicker = true
}
}
.help("Create worktree")
.popover(isPresented: $showRepoPicker) {
RepoPickerPopover(
repositories: snapshot.repositories,
searchText: $repoSearchText
) { repo in
showRepoPicker = false
createSheetRepo = repo
}
}
.onChange(of: showRepoPicker) { isShowing in
if isShowing { repoSearchText = "" }
}
}

if worktrees.isEmpty {
Text("No worktrees")
.foregroundStyle(.secondary)
} else {
ForEach(worktrees) { wt in
worktreeDisclosureGroup(
Expand All @@ -552,6 +572,56 @@ struct WorktrunkSidebarView: View {
}
}

@ViewBuilder
private func sidebarHeaderRow(
_ title: String,
topPadding: CGFloat = 0,
bottomPadding: CGFloat = 0,
leadingShift: CGFloat = -16,
addAction: (() -> Void)? = nil,
addDisabled: Bool = false
) -> some View {
HStack(spacing: 8) {
HStack(spacing: 8) {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(nil)
}
.padding(.leading, leadingShift)

Spacer(minLength: 0)

if let addAction {
Button(action: addAction) {
Image(systemName: "plus")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.disabled(addDisabled)
.help(addDisabled ? "Add a repository first" : "Create worktree")
.padding(.trailing, 8)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.padding(.top, topPadding)
.padding(.bottom, bottomPadding)
.listRowInsets(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 0))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}

private func presentCreateWorktree(from snapshot: WorktrunkStore.SidebarSnapshot) {
guard !snapshot.repositories.isEmpty else { return }

if snapshot.repositories.count == 1, let repo = snapshot.repositories.first {
createSheetRepo = repo
} else {
showRepoPicker = true
}
}

private func toggleSidebarListMode() {
if store.sidebarListMode == .flatWorktrees {
store.sidebarListMode = .nestedByRepo
Expand Down Expand Up @@ -805,6 +875,11 @@ struct WorktrunkSidebarView: View {
}
}

private struct SidebarTabItem {
let tab: WorktrunkOpenTabsModel.Tab
let worktree: WorktrunkStore.Worktree
}

private enum SidebarFileURLDrop {
static func loadURL(from providers: [NSItemProvider], completion: @escaping (URL?) -> Void) -> Bool {
guard let provider = providers.first(where: {
Expand Down
Loading