Skip to content
Merged
Show file tree
Hide file tree
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
107 changes: 92 additions & 15 deletions supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,33 @@ struct SidebarItemGroup: Identifiable, Equatable, Sendable {
}
}

/// Per-repo tally of rows hoisted into the highlight sections, surfaced as a
/// muted summary line at the bottom of the repo section so a hoisted row stays
/// discoverable from its origin repo without rendering a duplicate. `revealTarget`
/// is the row a click scrolls to: the repo's first pinned hoist, else its first
/// active hoist.
struct SidebarHoistSummary: Equatable, Sendable {
let pinnedCount: Int
let activeCount: Int
let revealTarget: Worktree.ID

/// Nil when neither bucket has a row, so a `(0, 0)` summary is unrepresentable.
init?(pinnedCount: Int, activeCount: Int, revealTarget: Worktree.ID) {
guard pinnedCount > 0 || activeCount > 0 else { return nil }
self.pinnedCount = pinnedCount
self.activeCount = activeCount
self.revealTarget = revealTarget
}

/// Spoken VoiceOver form, pinned before active, omitting a zero bucket.
var label: String {
var parts: [String] = []
if pinnedCount > 0 { parts.append("+\(pinnedCount) \(SidebarStructure.HighlightKind.pinned.summaryNoun)") }
if activeCount > 0 { parts.append("+\(activeCount) \(SidebarStructure.HighlightKind.active.summaryNoun)") }
return parts.joined(separator: ", ")
}
}

/// Single source of truth for what the sidebar List renders. The reducer
/// builds it once per `recomputeSidebarStructure()` and caches it on
/// `RepositoriesFeature.State.sidebarStructure`; the view walks `sections`
Expand All @@ -175,6 +202,14 @@ struct SidebarStructure: Equatable, Sendable {
case .active: "Active"
}
}

/// Lowercase noun used in the per-repo hoist summary line.
var summaryNoun: String {
switch self {
case .pinned: "pinned"
case .active: "active"
}
}
}

enum Section: Equatable, Sendable, Identifiable {
Expand Down Expand Up @@ -223,6 +258,9 @@ struct SidebarStructure: Equatable, Sendable {
/// subtitle on highlight rows. Built only for repos that contributed at
/// least one row to the highlight sections.
var repositoryHighlightByID: [Repository.ID: SidebarHighlightRepoTag]
/// Per-repo hoisted-row tally; git repos only, built only for repos that
/// contributed at least one highlight row.
var hoistSummaryByRepositoryID: [Repository.ID: SidebarHoistSummary]
/// Outer-ForEach data ordering for repository sections. The view uses
/// this to translate `.onMove` flat offsets into the index space the
/// `.repositoriesMoved` reducer action expects.
Expand All @@ -234,6 +272,7 @@ struct SidebarStructure: Equatable, Sendable {
hotkeySlots: [],
slotByID: [:],
repositoryHighlightByID: [:],
hoistSummaryByRepositoryID: [:],
reorderableRepositoryIDs: []
)

Expand All @@ -246,6 +285,7 @@ struct SidebarStructure: Equatable, Sendable {
hotkeySlots: [],
slotByID: [:],
repositoryHighlightByID: [:],
hoistSummaryByRepositoryID: [:],
reorderableRepositoryIDs: []
)
}
Expand Down Expand Up @@ -412,7 +452,8 @@ extension RepositoriesFeature.Action {
.refreshWorktrees, .reloadRepositories,
.setSidebarSelectedWorktreeIDs,
.openRepositories,
.revealSelectedWorktreeInSidebar, .consumePendingSidebarReveal,
.revealSelectedWorktreeInSidebar, .revealHoistedWorktreeInSidebar,
.consumePendingSidebarReveal,
.createRandomWorktree,
.promptedWorktreeCreationDataLoaded, .promptedWorktreeBranchesLoaded,
.startPromptedWorktreeCreation,
Expand Down Expand Up @@ -502,6 +543,7 @@ extension RepositoriesFeature.State {
hotkeySlots: [],
slotByID: [:],
repositoryHighlightByID: [:],
hoistSummaryByRepositoryID: [:],
reorderableRepositoryIDs: []
)
}
Expand All @@ -525,15 +567,18 @@ extension RepositoriesFeature.State {
sections: sections
)

let highlightProjections = computeRepositoryHighlightProjections(
pinnedHoisted: hoists.pinned,
activeHoisted: hoists.active
)

return SidebarStructure(
sections: sections,
hoistedRowIDs: hoists.hoistedSet,
hotkeySlots: hotkey.slots,
slotByID: hotkey.slotByID,
repositoryHighlightByID: computeRepositoryHighlightTags(
pinnedHoisted: hoists.pinned,
activeHoisted: hoists.active
),
repositoryHighlightByID: highlightProjections.tags,
hoistSummaryByRepositoryID: highlightProjections.summaries,
reorderableRepositoryIDs: repoSections.reorderableRepositoryIDs
)
}
Expand Down Expand Up @@ -685,23 +730,47 @@ extension RepositoriesFeature.State {
return HotkeyOrdering(slots: hotkeyWorktreeSlots(for: order), slotByID: slotByID)
}

private func computeRepositoryHighlightTags(
/// Per-repo highlight projections derived in a single walk of the hoisted
/// arrays.
private struct HighlightProjections {
var tags: [Repository.ID: SidebarHighlightRepoTag]
var summaries: [Repository.ID: SidebarHoistSummary]
}

/// Resolve the highlight tags (every contributing repo) and the hoist
/// summaries (git repos only) in one pass. Walks the ordered arrays, not
/// `hoistedSet`, so `revealTarget` is deterministic: a repo's first pinned
/// hoist, else its first active.
private func computeRepositoryHighlightProjections(
pinnedHoisted: [Worktree.ID],
activeHoisted: [Worktree.ID]
) -> [Repository.ID: SidebarHighlightRepoTag] {
guard !pinnedHoisted.isEmpty || !activeHoisted.isEmpty else { return [:] }
) -> HighlightProjections {
guard !pinnedHoisted.isEmpty || !activeHoisted.isEmpty else {
return HighlightProjections(tags: [:], summaries: [:])
}

var contributingRepoIDs: Set<Repository.ID> = []
var pinnedCounts: [Repository.ID: Int] = [:]
var activeCounts: [Repository.ID: Int] = [:]
var firstPinned: [Repository.ID: Worktree.ID] = [:]
var firstActive: [Repository.ID: Worktree.ID] = [:]

for id in pinnedHoisted {
if let repoID = sidebarItems[id: id]?.repositoryID {
contributingRepoIDs.insert(repoID)
}
guard let repoID = sidebarItems[id: id]?.repositoryID else { continue }
contributingRepoIDs.insert(repoID)
pinnedCounts[repoID, default: 0] += 1
if firstPinned[repoID] == nil { firstPinned[repoID] = id }
}
for id in activeHoisted {
if let repoID = sidebarItems[id: id]?.repositoryID {
contributingRepoIDs.insert(repoID)
}
guard let repoID = sidebarItems[id: id]?.repositoryID else { continue }
contributingRepoIDs.insert(repoID)
activeCounts[repoID, default: 0] += 1
if firstActive[repoID] == nil { firstActive[repoID] = id }
}

// Output is keyed by repo id, so build order is irrelevant.
var tags: [Repository.ID: SidebarHighlightRepoTag] = [:]
var summaries: [Repository.ID: SidebarHoistSummary] = [:]
for repoID in contributingRepoIDs {
guard let repository = repositories[id: repoID] else { continue }
let section = sidebar.sections[repoID]
Expand All @@ -710,8 +779,16 @@ extension RepositoriesFeature.State {
repoColor: section?.color,
hostInfo: repository.host?.displayAuthority
)
guard repository.isGitRepository, let revealTarget = firstPinned[repoID] ?? firstActive[repoID] else {
continue
}
summaries[repoID] = SidebarHoistSummary(
pinnedCount: pinnedCounts[repoID] ?? 0,
activeCount: activeCounts[repoID] ?? 0,
revealTarget: revealTarget
) // Non-nil: `revealTarget` exists only when a bucket contributed a row.
}
return tags
return HighlightProjections(tags: tags, summaries: summaries)
}

/// Walk the freshly-built sections to extract visible per-repo row IDs in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@
case worktreeHistoryBack
case worktreeHistoryForward
case revealSelectedWorktreeInSidebar
case revealHoistedWorktreeInSidebar(Worktree.ID)
case consumePendingSidebarReveal(Int)
case createRandomWorktree
case createRandomWorktreeInRepository(Repository.ID)
Expand Down Expand Up @@ -1254,7 +1255,7 @@
state.resetRowLifecycleSyncBeforeReconcile(itemID: worktreeID)
// Drop the worktree from every bucket in its section. The worktree is
// going away entirely so its current bucket doesn't matter.
state.$sidebar.withLock { sidebar in

Check warning on line 1258 in supacode/Features/Repositories/Reducer/RepositoriesFeature.swift

View workflow job for this annotation

GitHub Actions / build

result of call to 'withLock(_:fileID:filePath:line:column:)' is unused
sidebar.removeAnywhere(worktree: worktreeID, in: repositoryID)
}
_ = state.removeWorktree(worktreeID, repositoryID: repositoryID)
Expand Down Expand Up @@ -3321,6 +3322,13 @@
state.pendingSidebarReveal = .init(id: state.nextPendingSidebarRevealID, worktreeID: worktreeID)
return .none

case .revealHoistedWorktreeInSidebar(let worktreeID):
// The target lives in a highlight section, which is never collapsed,
// so no section / branch-prefix uncollapse is needed.
state.nextPendingSidebarRevealID += 1
state.pendingSidebarReveal = .init(id: state.nextPendingSidebarRevealID, worktreeID: worktreeID)
return .none

case .consumePendingSidebarReveal(let pendingSidebarRevealID):
guard state.pendingSidebarReveal?.id == pendingSidebarRevealID else { return .none }
state.pendingSidebarReveal = nil
Expand Down Expand Up @@ -5129,7 +5137,7 @@
pendingWorktrees.removeAll { $0.id == worktreeID }
// Drop the worktree from every bucket in its section. The worktree is going
// away entirely so the current bucket doesn't matter.
$sidebar.withLock { sidebar in

Check warning on line 5140 in supacode/Features/Repositories/Reducer/RepositoriesFeature.swift

View workflow job for this annotation

GitHub Actions / build

result of call to 'withLock(_:fileID:filePath:line:column:)' is unused
sidebar.removeAnywhere(worktree: worktreeID, in: repositoryID)
}
RepositoriesFeature.syncSidebar(&self)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,17 @@ struct SidebarHighlightSection: View {
}

extension SidebarStructure.HighlightKind {
fileprivate var indicatorColor: Color {
var indicatorColor: Color {
switch self {
case .pinned: .orange
case .active: .blue
}
}
}

private struct SidebarHighlightHeaderDot: View {
/// Colored dot shown after a highlight section title and reused after each
/// bucket label in the per-repo hoist summary line.
struct SidebarHighlightHeaderDot: View {
let color: Color
@Environment(\.pixelLength) private var pixelLength

Expand Down
63 changes: 63 additions & 0 deletions supacode/Features/Repositories/Views/SidebarListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ private struct SidebarSectionDispatcher: View {
SidebarGitRepositorySection(
repository: repository,
groups: groups,
hoistSummary: structure.hoistSummaryByRepositoryID[repositoryID],
shortcutHintByID: shortcutHintByID,
selectedWorktreeIDs: selectedWorktreeIDs,
store: store,
Expand All @@ -256,6 +257,9 @@ private struct SidebarSectionDispatcher: View {
private struct SidebarGitRepositorySection: View {
let repository: Repository
let groups: [SidebarItemGroup]
/// Non-nil when one or more of this repo's rows were hoisted into the
/// highlight sections; rendered as a muted summary line under the rows.
let hoistSummary: SidebarHoistSummary?
let shortcutHintByID: [Worktree.ID: String]
let selectedWorktreeIDs: Set<Worktree.ID>
@Bindable var store: StoreOf<RepositoriesFeature>
Expand All @@ -273,6 +277,13 @@ private struct SidebarGitRepositorySection: View {
store: store,
terminalManager: terminalManager
)
if let hoistSummary {
SidebarHoistSummaryRow(
repositoryName: Repository.sidebarDisplayName(custom: section?.title, fallback: repository.name),
summary: hoistSummary,
store: store
)
}
} header: {
RepoSectionHeaderView(
name: repository.name,
Expand Down Expand Up @@ -303,6 +314,58 @@ private struct SidebarGitRepositorySection: View {
}
}

/// Muted, unselectable line under a repo's rows summarizing how many were
/// hoisted into the Pinned / Active sections, with a click that scrolls up to
/// them. Carries no `.tag`, so it stays out of selection and arrow-key
/// navigation; lives inside the `Section` body so it folds away when the repo
/// section is collapsed.
private struct SidebarHoistSummaryRow: View {
let repositoryName: String
let summary: SidebarHoistSummary
let store: StoreOf<RepositoriesFeature>

var body: some View {
Button {
store.send(.revealHoistedWorktreeInSidebar(summary.revealTarget))
} label: {
HStack(spacing: 8) {
if summary.pinnedCount > 0 {
SidebarHoistSummarySegment(kind: .pinned, count: summary.pinnedCount)
}
if summary.activeCount > 0 {
SidebarHoistSummarySegment(kind: .active, count: summary.activeCount)
}
Spacer(minLength: 0)
}
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.contentShape(.interaction, .rect)
}
.buttonStyle(.plain)
.listRowInsets(.leading, 0)
.listRowInsets(.trailing, 4)
.listRowInsets(.vertical, 4)
.moveDisabled(true)
.help("Show \(repositoryName)'s pinned and active worktrees")
.accessibilityLabel("\(summary.label) above. Scroll to them.")
}
}

/// One bucket of the hoist summary: its count followed by the same colored dot
/// the matching highlight section header shows.
private struct SidebarHoistSummarySegment: View {
let kind: SidebarStructure.HighlightKind
let count: Int

var body: some View {
HStack(spacing: 4) {
Text("+\(count) \(kind.summaryNoun)")
SidebarHighlightHeaderDot(color: kind.indicatorColor)
}
}
}

private struct SidebarSectionActionsView: View {
let repositoryID: Repository.ID
let isRemovingRepository: Bool
Expand Down
15 changes: 15 additions & 0 deletions supacodeTests/RepositoriesFeatureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,21 @@ struct RepositoriesFeatureTests {
}
}

@Test func revealHoistedWorktreeInSidebarRevealsTheGivenWorktree() async {
let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt")
let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree])
let store = TestStore(initialState: makeState(repositories: [repository])) {
RepositoriesFeature()
}

// No selection and no uncollapse: the target lives in a highlight section,
// and the action reveals the id it is handed directly.
await store.send(.revealHoistedWorktreeInSidebar(worktree.id)) {
$0.nextPendingSidebarRevealID = 1
$0.pendingSidebarReveal = .init(id: 1, worktreeID: worktree.id)
}
}

@Test func consumePendingSidebarRevealClearsMatchingRequest() async {
let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt")
let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree])
Expand Down
Loading
Loading