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+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 1badc1ca..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) } @@ -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,44 @@ final class StatusBarMenuBuilder { now: now ) } + return models + } + + private func localScopeViewModels( + session: Session, + settings: UserSettings, + now: Date + ) -> [RepositoryDisplayModel] { + // 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] = [] + 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/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/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..875f35b2 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: "Local" 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 + } } 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]] = [:]