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
8 changes: 6 additions & 2 deletions Sources/RepoBar/App/AppState+Refresh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}

Expand All @@ -274,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
}
Expand Down Expand Up @@ -312,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) })
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions Sources/RepoBar/App/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
45 changes: 45 additions & 0 deletions Sources/RepoBar/Models/RepositoryDisplayModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:")
}
}
10 changes: 6 additions & 4 deletions Sources/RepoBar/StatusBar/StatusBarMenuBuilder+MenuItems.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand All @@ -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
}

Expand Down
56 changes: 49 additions & 7 deletions Sources/RepoBar/StatusBar/StatusBarMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -185,7 +190,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) }
Expand Down Expand Up @@ -263,6 +268,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,
Expand All @@ -280,20 +290,52 @@ final class StatusBarMenuBuilder {
: session.repositories
let sorted = RepositoryPipeline.apply(baseRepos, query: query)
let displayIndex = session.menuDisplayIndex
return sorted.map { repo in
displayIndex[repo.fullName]
let models = sorted.map { repo in
displayIndex[repo.fullName.lowercased()]
?? RepositoryDisplayModel(
repo: repo,
localStatus: session.localRepoIndex.status(for: repo),
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?.lowercased(),
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.")
}
Expand Down
18 changes: 11 additions & 7 deletions Sources/RepoBar/StatusBar/StatusBarMenuManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
32 changes: 24 additions & 8 deletions Sources/RepoBar/Support/LocalRepoManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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] = []
Expand Down
10 changes: 7 additions & 3 deletions Sources/RepoBar/Support/MenuRepoFilters.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
28 changes: 26 additions & 2 deletions Sources/RepoBar/Views/MenuFilterViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,34 @@ import SwiftUI
struct MenuRepoFiltersView: View {
@Bindable var session: Session

private var availableFilters: [MenuRepoSelection] {
if session.account.isLoggedIn {
return MenuRepoSelection.allCases
}
// Only local filter when logged out (All/Pinned/Work require GitHub)
return [.local]
}

private var filterSelection: Binding<MenuRepoSelection> {
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) {
ForEach(MenuRepoSelection.allCases, id: \.self) { selection in
Picker("Filter", selection: self.filterSelection) {
ForEach(self.availableFilters, id: \.self) { selection in
Text(selection.label).tag(selection)
}
}
Expand Down
Loading
Loading