From cae2dd435b826219910cf95522193ef9fd877b44 Mon Sep 17 00:00:00 2001 From: Fadi Al Zuabi Date: Sun, 18 Jan 2026 17:09:13 -0800 Subject: [PATCH 1/4] feat: add Local filter to show all local git repositories Add a new "Local" filter option in the repository filter bar that displays all git repositories found in the configured local folder, including repos that don't have GitHub remotes (local-only repos). Changes: - Add `local` case to MenuRepoSelection enum with isLocalScope property - Add RepositoryDisplayModel initializer for local-only repos - Add isLocalOnly computed property for future submenu handling - Fix LocalRepoManager to scan ALL discovered repos, not just GitHub-matching - Reduce filter picker size to accommodate 4 filter options This enables users to see and manage local repositories that exist only on their machine without requiring a GitHub remote. Co-Authored-By: Claude Opus 4.5 --- .../Models/RepositoryDisplayModel.swift | 45 +++++++++++++++++++ .../StatusBar/StatusBarMenuBuilder.swift | 38 +++++++++++++++- .../RepoBar/Support/LocalRepoManager.swift | 32 +++++++++---- Sources/RepoBar/Support/MenuRepoFilters.swift | 10 +++-- 4 files changed, 113 insertions(+), 12 deletions(-) diff --git a/Sources/RepoBar/Models/RepositoryDisplayModel.swift b/Sources/RepoBar/Models/RepositoryDisplayModel.swift index 8376f578..f103efdb 100644 --- a/Sources/RepoBar/Models/RepositoryDisplayModel.swift +++ b/Sources/RepoBar/Models/RepositoryDisplayModel.swift @@ -83,4 +83,49 @@ struct RepositoryDisplayModel: Identifiable, Equatable { Stat(id: "forks", label: "Forks", value: repo.stats.forks, systemImage: "tuningfork") ] } + + init(localStatus: LocalRepoStatus, now: Date = Date()) { + let placeholderRepo = Repository( + id: "local:\(localStatus.path.path)", + name: localStatus.name, + owner: "", + sortOrder: nil, + error: nil, + rateLimitedUntil: nil, + ciStatus: .unknown, + openIssues: 0, + openPulls: 0, + latestRelease: nil, + latestActivity: nil, + traffic: nil, + heatmap: [] + ) + self.source = placeholderRepo + self.id = placeholderRepo.id + self.title = localStatus.displayName + self.ciStatus = .unknown + self.ciRunCount = nil + self.issues = 0 + self.pulls = 0 + self.trafficVisitors = nil + self.trafficCloners = nil + self.stars = 0 + self.forks = 0 + self.heatmap = [] + self.sortOrder = nil + self.error = nil + self.rateLimitedUntil = nil + self.localStatus = localStatus + self.releaseLine = nil + self.lastPushAge = nil + self.activityLine = nil + self.activityURL = nil + self.activityEvents = [] + self.latestActivityAge = nil + self.stats = [] + } + + var isLocalOnly: Bool { + self.source.owner.isEmpty && self.id.hasPrefix("local:") + } } diff --git a/Sources/RepoBar/StatusBar/StatusBarMenuBuilder.swift b/Sources/RepoBar/StatusBar/StatusBarMenuBuilder.swift index 1badc1ca..a0a7398e 100644 --- a/Sources/RepoBar/StatusBar/StatusBarMenuBuilder.swift +++ b/Sources/RepoBar/StatusBar/StatusBarMenuBuilder.swift @@ -263,6 +263,11 @@ final class StatusBarMenuBuilder { let session = self.appState.session let selection = session.menuRepoSelection let settings = session.settings + + if selection.isLocalScope { + return self.localScopeViewModels(session: session, settings: settings, now: now) + } + let scope: RepositoryScope = selection.isPinnedScope ? .pinned : .all let query = RepositoryQuery( scope: scope, @@ -280,7 +285,7 @@ final class StatusBarMenuBuilder { : session.repositories let sorted = RepositoryPipeline.apply(baseRepos, query: query) let displayIndex = session.menuDisplayIndex - return sorted.map { repo in + let models = sorted.map { repo in displayIndex[repo.fullName] ?? RepositoryDisplayModel( repo: repo, @@ -288,12 +293,43 @@ final class StatusBarMenuBuilder { now: now ) } + return models + } + + private func localScopeViewModels( + session: Session, + settings: UserSettings, + now: Date + ) -> [RepositoryDisplayModel] { + let localRepos = session.localRepoIndex.all + let displayIndex = session.menuDisplayIndex + + var models: [RepositoryDisplayModel] = [] + for localStatus in localRepos { + if let fullName = localStatus.fullName, + let existingModel = displayIndex[fullName] { + models.append(existingModel) + } else { + let model = RepositoryDisplayModel(localStatus: localStatus, now: now) + models.append(model) + } + } + + let limit = settings.repoList.displayLimit + if limit > 0, models.count > limit { + return Array(models.prefix(limit)) + } + return models } private func emptyStateMessage(for session: Session) -> (String, String) { let hasPinned = !session.settings.repoList.pinnedRepositories.isEmpty let isPinnedScope = session.menuRepoSelection.isPinnedScope + let isLocalScope = session.menuRepoSelection.isLocalScope let hasFilter = session.menuRepoSelection.onlyWith.isActive + if isLocalScope { + return ("No local repositories", "Clone a repository or set your projects folder in Settings.") + } if isPinnedScope, !hasPinned { return ("No pinned repositories", "Pin a repository to see activity here.") } diff --git a/Sources/RepoBar/Support/LocalRepoManager.swift b/Sources/RepoBar/Support/LocalRepoManager.swift index 4e1cd003..16e66d8b 100644 --- a/Sources/RepoBar/Support/LocalRepoManager.swift +++ b/Sources/RepoBar/Support/LocalRepoManager.swift @@ -38,16 +38,28 @@ actor LocalRepoManager { let fallbackURL = URL(fileURLWithPath: PathFormatter.expandTilde(rootPath), isDirectory: true) let resolvedBookmark = rootBookmarkData.flatMap(SecurityScopedBookmark.resolve) - let scopedURL = resolvedBookmark ?? fallbackURL - let didStart = scopedURL.startAccessingSecurityScopedResource() + // Try security-scoped bookmark first, fall back to direct path access + let (scopedURL, didStart): (URL, Bool) = { + if let resolved = resolvedBookmark { + let started = resolved.startAccessingSecurityScopedResource() + if started { + return (resolved, true) + } + } + // Bookmark failed or didn't start - try fallback URL directly + return (fallbackURL, false) + }() + defer { if didStart { scopedURL.stopAccessingSecurityScopedResource() } } - if rootBookmarkData != nil, resolvedBookmark == nil || didStart == false { + // Only return accessDenied if we truly cannot access the folder + let canAccess = didStart || FileManager.default.isReadableFile(atPath: scopedURL.path) + if !canAccess { return SnapshotResult(discoveredCount: 0, repoIndex: .empty, accessDenied: true) } @@ -149,11 +161,15 @@ actor LocalRepoManager { guard repoRoots.isEmpty == false else { return ([], []) } let matchKeys = Set(options.matchRepoNames.map { $0.lowercased() }) - let interesting: [URL] = if matchKeys.isEmpty { - [] - } else { - repoRoots.filter { matchKeys.contains($0.lastPathComponent.lowercased()) } - } + // Scan ALL discovered repos, not just GitHub-matching ones. + // This enables the Local filter to show all local repos including local-only ones. + // Prioritize GitHub-matching repos first, then include non-matching ones. + let interesting: [URL] = { + guard !matchKeys.isEmpty else { return repoRoots } + let matching = repoRoots.filter { matchKeys.contains($0.lastPathComponent.lowercased()) } + let nonMatching = repoRoots.filter { !matchKeys.contains($0.lastPathComponent.lowercased()) } + return matching + nonMatching + }() var cached: [LocalRepoStatus] = [] var refresh: [URL] = [] diff --git a/Sources/RepoBar/Support/MenuRepoFilters.swift b/Sources/RepoBar/Support/MenuRepoFilters.swift index 8f528a4f..3b157af4 100644 --- a/Sources/RepoBar/Support/MenuRepoFilters.swift +++ b/Sources/RepoBar/Support/MenuRepoFilters.swift @@ -3,28 +3,32 @@ import RepoBarCore enum MenuRepoSelection: String, CaseIterable, Hashable { case all case pinned + case local case work var label: String { switch self { case .all: "All" case .pinned: "Pinned" + case .local: "Loc" case .work: "Work" } } var onlyWith: RepositoryOnlyWith { switch self { - case .all: + case .all, .pinned, .local: .none case .work: RepositoryOnlyWith(requireIssues: true, requirePRs: true) - case .pinned: - .none } } var isPinnedScope: Bool { self == .pinned } + + var isLocalScope: Bool { + self == .local + } } From c853180fc562afbfc558331980a1f10bfedeb465 Mon Sep 17 00:00:00 2001 From: Fadi Al Zuabi Date: Sun, 18 Jan 2026 21:13:04 -0800 Subject: [PATCH 2/4] fix: improve Local filter stability and worktree handling - Use repo.id instead of repo.title for menu cache keys (fixes duplicate name issues) - Remove cached menu items from parent menu before reusing (prevents crash) - Defer menu rebuild to next run loop to avoid modifying during layout - Filter worktrees from Local scope (accessible via parent's Switch Worktree submenu) - Handle duplicate fullNames in LocalRepoIndex (worktrees share same remote) - Change filter label from "Loc" to "Local" Co-Authored-By: Claude Opus 4.5 --- .../StatusBarMenuBuilder+MenuItems.swift | 10 ++++++---- .../StatusBar/StatusBarMenuBuilder.swift | 5 +++-- .../StatusBar/StatusBarMenuManager.swift | 18 +++++++++++------- Sources/RepoBar/Support/MenuRepoFilters.swift | 2 +- .../LocalProjects/LocalRepoStatus.swift | 14 ++++++++++---- 5 files changed, 31 insertions(+), 18 deletions(-) diff --git a/Sources/RepoBar/StatusBar/StatusBarMenuBuilder+MenuItems.swift b/Sources/RepoBar/StatusBar/StatusBarMenuBuilder+MenuItems.swift index 86546129..5ce63621 100644 --- a/Sources/RepoBar/StatusBar/StatusBarMenuBuilder+MenuItems.swift +++ b/Sources/RepoBar/StatusBar/StatusBarMenuBuilder+MenuItems.swift @@ -23,7 +23,9 @@ extension StatusBarMenuBuilder { } ) let submenu = self.repoSubmenu(for: repo, isPinned: isPinned) - if let cached = self.repoMenuItemCache[repo.title] { + if let cached = self.repoMenuItemCache[repo.id] { + // Remove from current menu if attached (prevents crash when reusing cached items) + cached.menu?.removeItem(cached) self.menuItemFactory.updateItem(cached, with: card, highlightable: true, showsSubmenuIndicator: true) cached.isEnabled = true cached.submenu = submenu @@ -32,7 +34,7 @@ extension StatusBarMenuBuilder { return cached } let item = self.viewItem(for: card, enabled: true, highlightable: true, submenu: submenu) - self.repoMenuItemCache[repo.title] = item + self.repoMenuItemCache[repo.id] = item return item } @@ -59,11 +61,11 @@ extension StatusBarMenuBuilder { changelogHeadline: changelogHeadline, isPinned: isPinned ) - if let cached = self.repoSubmenuCache[repo.title], cached.signature == signature { + if let cached = self.repoSubmenuCache[repo.id], cached.signature == signature { return cached.menu } let menu = self.makeRepoSubmenu(for: repo, isPinned: isPinned) - self.repoSubmenuCache[repo.title] = RepoSubmenuCacheEntry(menu: menu, signature: signature) + self.repoSubmenuCache[repo.id] = RepoSubmenuCacheEntry(menu: menu, signature: signature) return menu } diff --git a/Sources/RepoBar/StatusBar/StatusBarMenuBuilder.swift b/Sources/RepoBar/StatusBar/StatusBarMenuBuilder.swift index a0a7398e..59a609c5 100644 --- a/Sources/RepoBar/StatusBar/StatusBarMenuBuilder.swift +++ b/Sources/RepoBar/StatusBar/StatusBarMenuBuilder.swift @@ -185,7 +185,7 @@ final class StatusBarMenuBuilder { if index < repos.count - 1 { items.append(self.repoCardSeparator()) } - usedRepoKeys.insert(repo.title) + usedRepoKeys.insert(repo.id) } self.repoMenuItemCache = self.repoMenuItemCache.filter { usedRepoKeys.contains($0.key) } self.repoSubmenuCache = self.repoSubmenuCache.filter { usedRepoKeys.contains($0.key) } @@ -301,7 +301,8 @@ final class StatusBarMenuBuilder { settings: UserSettings, now: Date ) -> [RepositoryDisplayModel] { - let localRepos = session.localRepoIndex.all + // Filter out worktrees - they appear in parent repo's "Switch Worktree" submenu + let localRepos = session.localRepoIndex.all.filter { $0.worktreeName == nil } let displayIndex = session.menuDisplayIndex var models: [RepositoryDisplayModel] = [] diff --git a/Sources/RepoBar/StatusBar/StatusBarMenuManager.swift b/Sources/RepoBar/StatusBar/StatusBarMenuManager.swift index 93bc3c04..e72876f7 100644 --- a/Sources/RepoBar/StatusBar/StatusBarMenuManager.swift +++ b/Sources/RepoBar/StatusBar/StatusBarMenuManager.swift @@ -92,13 +92,17 @@ final class StatusBarMenuManager: NSObject, NSMenuDelegate { @objc func menuFiltersChanged() { guard let menu = self.mainMenu else { return } - self.recentListCoordinator.clearMenus() - self.appState.persistSettings() - let plan = self.menuBuilder.mainMenuPlan() - self.menuBuilder.populateMainMenu(menu, repos: plan.repos) - self.lastMainMenuSignature = plan.signature - self.menuBuilder.refreshMenuViewHeights(in: menu) - menu.update() + // Defer menu rebuild to next run loop to avoid modifying menu during layout + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.recentListCoordinator.clearMenus() + self.appState.persistSettings() + let plan = self.menuBuilder.mainMenuPlan() + self.menuBuilder.populateMainMenu(menu, repos: plan.repos) + self.lastMainMenuSignature = plan.signature + self.menuBuilder.refreshMenuViewHeights(in: menu) + menu.update() + } } @objc private func recentListFiltersChanged() { diff --git a/Sources/RepoBar/Support/MenuRepoFilters.swift b/Sources/RepoBar/Support/MenuRepoFilters.swift index 3b157af4..875f35b2 100644 --- a/Sources/RepoBar/Support/MenuRepoFilters.swift +++ b/Sources/RepoBar/Support/MenuRepoFilters.swift @@ -10,7 +10,7 @@ enum MenuRepoSelection: String, CaseIterable, Hashable { switch self { case .all: "All" case .pinned: "Pinned" - case .local: "Loc" + case .local: "Local" case .work: "Work" } } diff --git a/Sources/RepoBarCore/LocalProjects/LocalRepoStatus.swift b/Sources/RepoBarCore/LocalProjects/LocalRepoStatus.swift index 64ff25db..41ecdd59 100644 --- a/Sources/RepoBarCore/LocalProjects/LocalRepoStatus.swift +++ b/Sources/RepoBarCore/LocalProjects/LocalRepoStatus.swift @@ -175,10 +175,16 @@ public struct LocalRepoIndex: Equatable, Sendable { public init(statuses: [LocalRepoStatus], preferredPathsByFullName: [String: String] = [:]) { self.all = statuses self.preferredPathsByFullName = preferredPathsByFullName - self.byFullName = Dictionary(uniqueKeysWithValues: statuses.compactMap { status in - status.fullName.map { ($0, status) } - }) - self.byPath = Dictionary(uniqueKeysWithValues: statuses.map { ($0.path.path, $0) }) + // Use uniquingKeysWith to handle duplicate fullNames (e.g., worktrees sharing same remote) + self.byFullName = Dictionary( + statuses.compactMap { status in status.fullName.map { ($0, status) } }, + uniquingKeysWith: { first, _ in first } + ) + // Use uniquingKeysWith to handle any duplicate paths + self.byPath = Dictionary( + statuses.map { ($0.path.path, $0) }, + uniquingKeysWith: { first, _ in first } + ) var nameIndex: [String: [LocalRepoStatus]] = [:] var nameIndexLowercased: [String: [LocalRepoStatus]] = [:] var fullNameIndexLowercased: [String: [LocalRepoStatus]] = [:] From 67a9fc76ce1284c18f94f558bf455f6354797f8e Mon Sep 17 00:00:00 2001 From: Fadi Al Zuabi Date: Sun, 18 Jan 2026 18:35:39 -0800 Subject: [PATCH 3/4] feat: enable local repos without GitHub login Allow users to view local repositories without signing into GitHub. The local scanning infrastructure was already independent - this change unlocks the UI by: - Adding isLoggedIn helper to AccountState for cleaner guard logic - Showing filter bar when logged out if local folder is configured - Showing repo list for local scope when logged out - Only displaying "Local" filter option when not signed in - Auto-selecting local filter when user logs out Users can now use RepoBar purely for local git repository management without requiring a GitHub account. Co-Authored-By: Claude Opus 4.5 --- Sources/RepoBar/App/AppState+Refresh.swift | 4 ++++ Sources/RepoBar/App/Session.swift | 5 +++++ .../RepoBar/StatusBar/StatusBarMenuBuilder.swift | 13 +++++++++---- Sources/RepoBar/Views/MenuFilterViews.swift | 11 ++++++++++- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/Sources/RepoBar/App/AppState+Refresh.swift b/Sources/RepoBar/App/AppState+Refresh.swift index b4498528..54932bf3 100644 --- a/Sources/RepoBar/App/AppState+Refresh.swift +++ b/Sources/RepoBar/App/AppState+Refresh.swift @@ -250,6 +250,10 @@ extension AppState { self.session.globalActivityError = nil self.session.globalCommitEvents = [] self.session.globalCommitError = nil + // Auto-select local filter when logged out (other filters require GitHub) + if self.session.menuRepoSelection != .local { + self.session.menuRepoSelection = .local + } } } diff --git a/Sources/RepoBar/App/Session.swift b/Sources/RepoBar/App/Session.swift index cc1b74d4..40fcd3f1 100644 --- a/Sources/RepoBar/App/Session.swift +++ b/Sources/RepoBar/App/Session.swift @@ -38,4 +38,9 @@ enum AccountState: Equatable { case loggedOut case loggingIn case loggedIn(UserIdentity) + + var isLoggedIn: Bool { + if case .loggedIn = self { return true } + return false + } } diff --git a/Sources/RepoBar/StatusBar/StatusBarMenuBuilder.swift b/Sources/RepoBar/StatusBar/StatusBarMenuBuilder.swift index 59a609c5..a55711a1 100644 --- a/Sources/RepoBar/StatusBar/StatusBarMenuBuilder.swift +++ b/Sources/RepoBar/StatusBar/StatusBarMenuBuilder.swift @@ -154,15 +154,20 @@ final class StatusBarMenuBuilder { } return [] case .filters: - guard case .loggedIn = session.account else { return [] } - guard session.hasLoadedRepositories else { return [] } + let isLoggedIn = session.account.isLoggedIn + let hasLocalFolder = session.settings.localProjects.rootPath?.isEmpty == false + // Show filters if logged in with repos, OR if local folder is configured + guard isLoggedIn ? session.hasLoadedRepositories : hasLocalFolder else { return [] } let filters = MenuRepoFiltersView(session: session) .padding(.horizontal, 0) .padding(.vertical, 0) return [self.viewItem(for: filters, enabled: true)] case .repoList: - guard case .loggedIn = session.account else { return [] } - if !session.hasLoadedRepositories { + let isLoggedIn = session.account.isLoggedIn + let isLocalScope = session.menuRepoSelection.isLocalScope + // Allow repo list for logged in users, or for local scope when logged out + guard isLoggedIn || isLocalScope else { return [] } + if isLoggedIn && !session.hasLoadedRepositories { let loading = MenuLoadingRowView() .padding(.horizontal, MenuStyle.sectionHorizontalPadding) .padding(.vertical, MenuStyle.sectionVerticalPadding) diff --git a/Sources/RepoBar/Views/MenuFilterViews.swift b/Sources/RepoBar/Views/MenuFilterViews.swift index 0d47bf15..35121392 100644 --- a/Sources/RepoBar/Views/MenuFilterViews.swift +++ b/Sources/RepoBar/Views/MenuFilterViews.swift @@ -4,10 +4,19 @@ import SwiftUI struct MenuRepoFiltersView: View { @Bindable var session: Session + private var availableFilters: [MenuRepoSelection] { + if session.account.isLoggedIn { + return MenuRepoSelection.allCases + } else { + // Only local filter when logged out (All/Pinned/Work require GitHub) + return [.local] + } + } + var body: some View { HStack(spacing: 1) { Picker("Filter", selection: self.$session.menuRepoSelection) { - ForEach(MenuRepoSelection.allCases, id: \.self) { selection in + ForEach(self.availableFilters, id: \.self) { selection in Text(selection.label).tag(selection) } } From 68636aba463c9d07b39c09c24ec93a8083706db3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 00:54:48 +0000 Subject: [PATCH 4/4] fix: harden local-only menus --- Sources/RepoBar/App/AppState+Refresh.swift | 4 +-- .../StatusBar/StatusBarMenuBuilder.swift | 4 +-- Sources/RepoBar/Views/MenuFilterViews.swift | 23 ++++++++++++--- .../LocalProjects/LocalRepoStatus.swift | 24 ++++++++++++--- .../LocalProjectsServiceTests.swift | 29 +++++++++++++++++++ 5 files changed, 72 insertions(+), 12 deletions(-) diff --git a/Sources/RepoBar/App/AppState+Refresh.swift b/Sources/RepoBar/App/AppState+Refresh.swift index 54932bf3..87ce3621 100644 --- a/Sources/RepoBar/App/AppState+Refresh.swift +++ b/Sources/RepoBar/App/AppState+Refresh.swift @@ -278,7 +278,7 @@ extension AppState { let models = repos.map { repo in RepositoryDisplayModel(repo: repo, localStatus: localIndex.status(for: repo), now: now) } - let index = Dictionary(uniqueKeysWithValues: models.map { ($0.title, $0) }) + let index = Dictionary(uniqueKeysWithValues: models.map { ($0.title.lowercased(), $0) }) await MainActor.run { self.session.menuDisplayIndex = index } @@ -316,7 +316,7 @@ extension AppState { now: capturedAt ) } - self.session.menuDisplayIndex = Dictionary(uniqueKeysWithValues: models.map { ($0.title, $0) }) + self.session.menuDisplayIndex = Dictionary(uniqueKeysWithValues: models.map { ($0.title.lowercased(), $0) }) } } } diff --git a/Sources/RepoBar/StatusBar/StatusBarMenuBuilder.swift b/Sources/RepoBar/StatusBar/StatusBarMenuBuilder.swift index a55711a1..4fdf15b2 100644 --- a/Sources/RepoBar/StatusBar/StatusBarMenuBuilder.swift +++ b/Sources/RepoBar/StatusBar/StatusBarMenuBuilder.swift @@ -291,7 +291,7 @@ final class StatusBarMenuBuilder { let sorted = RepositoryPipeline.apply(baseRepos, query: query) let displayIndex = session.menuDisplayIndex let models = sorted.map { repo in - displayIndex[repo.fullName] + displayIndex[repo.fullName.lowercased()] ?? RepositoryDisplayModel( repo: repo, localStatus: session.localRepoIndex.status(for: repo), @@ -312,7 +312,7 @@ final class StatusBarMenuBuilder { var models: [RepositoryDisplayModel] = [] for localStatus in localRepos { - if let fullName = localStatus.fullName, + if let fullName = localStatus.fullName?.lowercased(), let existingModel = displayIndex[fullName] { models.append(existingModel) } else { diff --git a/Sources/RepoBar/Views/MenuFilterViews.swift b/Sources/RepoBar/Views/MenuFilterViews.swift index 35121392..e20c7f56 100644 --- a/Sources/RepoBar/Views/MenuFilterViews.swift +++ b/Sources/RepoBar/Views/MenuFilterViews.swift @@ -7,15 +7,30 @@ struct MenuRepoFiltersView: View { private var availableFilters: [MenuRepoSelection] { if session.account.isLoggedIn { return MenuRepoSelection.allCases - } else { - // Only local filter when logged out (All/Pinned/Work require GitHub) - return [.local] } + // Only local filter when logged out (All/Pinned/Work require GitHub) + return [.local] + } + + private var filterSelection: Binding { + Binding( + get: { + if self.session.account.isLoggedIn { return self.session.menuRepoSelection } + return .local + }, + set: { newValue in + if self.session.account.isLoggedIn { + self.session.menuRepoSelection = newValue + } else { + self.session.menuRepoSelection = .local + } + } + ) } var body: some View { HStack(spacing: 1) { - Picker("Filter", selection: self.$session.menuRepoSelection) { + Picker("Filter", selection: self.filterSelection) { ForEach(self.availableFilters, id: \.self) { selection in Text(selection.label).tag(selection) } diff --git a/Sources/RepoBarCore/LocalProjects/LocalRepoStatus.swift b/Sources/RepoBarCore/LocalProjects/LocalRepoStatus.swift index 41ecdd59..9b511376 100644 --- a/Sources/RepoBarCore/LocalProjects/LocalRepoStatus.swift +++ b/Sources/RepoBarCore/LocalProjects/LocalRepoStatus.swift @@ -178,12 +178,12 @@ public struct LocalRepoIndex: Equatable, Sendable { // Use uniquingKeysWith to handle duplicate fullNames (e.g., worktrees sharing same remote) self.byFullName = Dictionary( statuses.compactMap { status in status.fullName.map { ($0, status) } }, - uniquingKeysWith: { first, _ in first } + uniquingKeysWith: { first, second in Self.preferredStatus(first, second) } ) // Use uniquingKeysWith to handle any duplicate paths self.byPath = Dictionary( statuses.map { ($0.path.path, $0) }, - uniquingKeysWith: { first, _ in first } + uniquingKeysWith: { first, second in Self.preferredStatus(first, second) } ) var nameIndex: [String: [LocalRepoStatus]] = [:] var nameIndexLowercased: [String: [LocalRepoStatus]] = [:] @@ -205,7 +205,7 @@ public struct LocalRepoIndex: Equatable, Sendable { return status } if let exact = self.byFullName[repo.fullName] { return exact } - if let match = self.uniqueStatus(in: self.byFullNameLowercased, forKey: repo.fullName.lowercased()) { + if let match = self.preferredStatus(in: self.byFullNameLowercased, forKey: repo.fullName.lowercased()) { return match } return self.uniqueStatus(forName: repo.name) @@ -216,7 +216,7 @@ public struct LocalRepoIndex: Equatable, Sendable { return status } if let exact = self.byFullName[fullName] { return exact } - if let match = self.uniqueStatus(in: self.byFullNameLowercased, forKey: fullName.lowercased()) { + if let match = self.preferredStatus(in: self.byFullNameLowercased, forKey: fullName.lowercased()) { return match } let name = fullName.split(separator: "/").last.map(String.init) @@ -233,4 +233,20 @@ public struct LocalRepoIndex: Equatable, Sendable { guard let matches = index[key], matches.count == 1 else { return nil } return matches.first } + + private func preferredStatus(in index: [String: [LocalRepoStatus]], forKey key: String) -> LocalRepoStatus? { + guard let matches = index[key], matches.isEmpty == false else { return nil } + return matches.reduce(matches[0]) { current, candidate in + Self.preferredStatus(current, candidate) + } + } + + private static func preferredStatus(_ lhs: LocalRepoStatus, _ rhs: LocalRepoStatus) -> LocalRepoStatus { + let lhsDepth = lhs.path.pathComponents.count + let rhsDepth = rhs.path.pathComponents.count + if lhsDepth != rhsDepth { return lhsDepth < rhsDepth ? lhs : rhs } + if lhs.worktreeName == nil, rhs.worktreeName != nil { return lhs } + if rhs.worktreeName == nil, lhs.worktreeName != nil { return rhs } + return lhs.path.path <= rhs.path.path ? lhs : rhs + } } diff --git a/Tests/RepoBarTests/LocalProjectsServiceTests.swift b/Tests/RepoBarTests/LocalProjectsServiceTests.swift index ddf96ff0..16fefb9e 100644 --- a/Tests/RepoBarTests/LocalProjectsServiceTests.swift +++ b/Tests/RepoBarTests/LocalProjectsServiceTests.swift @@ -182,6 +182,35 @@ struct LocalProjectsServiceTests { #expect(index.status(forFullName: "STEIPETE/CODEXBAR") != nil) } + @Test + func localRepoIndex_prefersHigherHierarchyForDuplicateFullNames() { + let worktree = LocalRepoStatus( + path: URL(fileURLWithPath: "/tmp/Repo/.work/feature"), + name: "Repo", + fullName: "owner/Repo", + branch: "feature", + isClean: true, + aheadCount: 0, + behindCount: 0, + syncState: .synced, + worktreeName: "feature" + ) + let root = LocalRepoStatus( + path: URL(fileURLWithPath: "/tmp/Repo"), + name: "Repo", + fullName: "owner/Repo", + branch: "main", + isClean: true, + aheadCount: 0, + behindCount: 0, + syncState: .synced + ) + let index = LocalRepoIndex(statuses: [worktree, root]) + + let selected = index.status(forFullName: "OWNER/REPO") + #expect(selected?.path.path == root.path.path) + } + @Test func localRepoIndex_prefersPreferredPath() { let primary = LocalRepoStatus(