diff --git a/AGENTS.md b/AGENTS.md index b96ab21d9..58fee190d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,18 +133,32 @@ Reducer ← .repositories(.worktreeInfoEvent(Event)) ← AsyncStream ## Sidebar performance - Per-row `SidebarItemFeature` state lives in `RepositoriesFeature.State.sidebarItems: IdentifiedArrayOf` (see commit `0a1ed578`, "Improve sidebar performance and refresh reliability"). The whole point is that a per-leaf mutation (notification tick, agent tool storm, running-script update) invalidates only that leaf's view, not every sibling. -- In a sidebar parent / aggregator view, NEVER read `store.state.sidebarItems[id: x].…` to fan out across rows. That reads through the `IdentifiedArray` on the parent store and observation-tracks the entire collection, so every per-row tick re-renders the parent. The chevron, group label, indent, and unrelated siblings all redraw on every leaf mutation, which produces visible scrolling lag in nested groups and any future aggregator. -- Do this instead: for each leaf id you need, derive a child store with `store.scope(state: \.sidebarItems[id: id], action: \.sidebarItems[id: id])`, then read `leafStore.state.X` through that scoped binding. Observation is bounded to that leaf, so the aggregator only re-renders when one of its actual descendants changes, and unrelated leaves are isolated. -- If you need to aggregate across many leaves in a parent row (group header indicators, batch summaries), extract a dedicated subview that takes only `parentStore: StoreOf` + `leafIDs: [SidebarItemID]` and does the per-id scope+read inside its own body. That isolates the re-render to the aggregator, not the surrounding row chrome. See `SidebarPathGroupAggregatedIndicators` in `SidebarItemsView.swift` for the canonical pattern. +- The sidebar view is a dumb renderer over `state.sidebarStructure` (see `Features/Repositories/BusinessLogic/SidebarStructure.swift`). The structure is computed inside the reducer's post-reduce hook so per-leaf reads stay in reducer context. `SidebarListView.body` reads only the cached `state.sidebarStructure`, never `sidebarItems[id:]` directly. If you find yourself iterating leaves from a view body to derive something, move that derivation into `computeSidebarStructure(...)` and let the cache flow back through. +- The post-reduce hook is gated by `\.sidebarStructureAutoRecompute` (default `true` in live + preview + test) so production and tests see the same fresh cache. TestStore expectations that mirror a structure-affecting action should call `$0.recomputeSidebarStructureIfChanged()` (post-reduce hook mirror) or `$0.reconcileSidebarForTesting()` (when the reducer body also calls `syncSidebar`). Legacy tests that don't care about the cache can opt out via `withDependencies { $0.sidebarStructureAutoRecompute = false }`. The set of actions that trigger recompute is enumerated in `RepositoriesFeature.Action.affectsSidebarStructure`; the `.sidebarItems` arm delegates to `SidebarItemFeature.Action.affectsSidebarStructure` so display-only per-leaf actions (diff stats, PR refresh, drag/focus/hint) skip the recompute entirely. Add new structure-affecting cases on whichever side is appropriate. +- The recompute helper uses an Equatable diff against the cached value, so a no-op rebuild (e.g. an action that touched per-leaf state in a way that didn't change classification) does NOT invalidate SwiftUI observation. +- When you need a row-level aggregator that ISN'T part of the global structure (per-group indicators inside a nested branch path, for example), extract a dedicated subview taking `parentStore: StoreOf` + `leafIDs: [SidebarItemID]` and per-leaf-scope inside its own body. See `SidebarPathGroupAggregatedIndicators` in `Features/Repositories/Views/SidebarItemsView.swift`. + +## Highlight Relevant Sidebar Items + +- Two View-menu toggles under the "Group Relevant Sidebar Rows" submenu (see `Commands/SidebarCommands.swift`): `@Shared(.sidebarGroupPinnedRows)` and `@Shared(.sidebarGroupActiveRows)`, both default `true` so the feature is discoverable on first launch. Each is independent: turning one off hides only its hoisted section; the rows fall back into their per-repo position. +- The sections are NOT collapsible (no `Section(isExpanded:)`). Visibility is purely the toggle state plus "are there qualifying rows". +- `SidebarStructure.sections` is the single ordered list the view renders. Cases: `.highlight(kind, rowIDs)` for Pinned / Active hoists, `.repository(id, groups)` for git repos (groups are precomputed `[SidebarItemGroup]` slot payloads), `.folder(id, rowID)` for folder repos, `.failedRepository(id, rootURL, message)`, `.placeholder` for the first-launch shimmer. `SidebarListView` does one `ForEach(structure.sections)` and dispatches via a single switch in `SidebarSectionDispatcher`. Non-repo cases set `.moveDisabled(true)` so the outer `.onMove` only reorders repository sections. +- `SidebarActiveClassification` (`BusinessLogic/SidebarStructure.swift`) is a 10-bucket priority enum keyed off four leaf-local flags (`hasUnseenNotifications`, `hasAgentAwaitingInput`, `!state.agents.isEmpty`, `!runningScripts.isEmpty`). The `hasAgent` flag matches visible agent-badge presence (any tracked instance, including `.idle`) so a row with an agent badge surfaces in Active even when the agent isn't actively working. Rows that don't classify are dropped from Active and (when the Pinned section is in play) fall to the bottom of Pinned alphabetically. `SidebarHighlightOrdering` is the pure helper that owns the priority + alphabetical sort; both have direct unit coverage in `SidebarActiveClassificationTests.swift` / `SidebarHighlightOrderingTests.swift`. Terminating-lifecycle rows (`SidebarItemFeature.State.Lifecycle.isTerminating`: `.archiving`, `.deletingScript`, `.deleting`) are excluded from the Active candidate set so a row mid-wind-down doesn't surface in the rail. `.pending` stays eligible because a pending row running a setup script is exactly what Active is meant to surface. +- `SidebarStructure.hoistedRowIDs` is the union of every hoisted row across both highlight sections. `SidebarItemGroup.computeSlots(...)` filters every per-repo slot (main / pinnedTail / pending / unpinnedTail) against this set, so a hoisted row never double-renders. A `seen: Set` dedupe inside `computeSlots` also catches a pre-existing double-bucket pre-state (same id in `.pinned` and `.unpinned`) so the row appears in at most one slot regardless of bucket state. +- Highlight rows get a colored `repo · trail` subtitle. The subtitle composes inside an `HStack` with `.layoutPriority(1)` on the trail so the disambiguating worktree name doesn't get truncated first under a narrow sidebar. The repo color and name come from `SidebarStructure.repositoryHighlightByID`, built once per recompute, and the repo name resolves through `Repository.sidebarDisplayName(custom:fallback:)` so the highlight tag and `RepoSectionHeaderView` stay in lockstep on a customized title. `.repositoryCustomization(.presented(.delegate(.save)))` is wired into `affectsSidebarStructure` so the cache flushes immediately on save. +- Hotkey numbering (⌃1..⌃0) reads `SidebarStructure.slotByID`; the view does one trivial join with `commandKeyObserver.isPressed` + shortcut overrides to convert slot index to display string. `SidebarStructure.hotkeySlots` is the projected `[HotkeyWorktreeSlot]` published to `focusedSceneValue(\.visibleHotkeyWorktreeRows, ...)` for the menu bar. +- When `@Shared(.sidebarNestWorktreesByBranch)` is on, the view's branch-tree builder re-sorts each git bucket alphabetically before nesting. `SidebarItemGroup.computeSlots(...)` mirrors that sort (case-insensitive `localizedCaseInsensitiveCompare` on `branchName`, matching `SidebarBranchNesting.buildRows`) so `slotByID` / `hotkeySlots` line up with the visible order. Toggling the option dispatches `.sidebarNestByBranchChanged` from `SidebarListView.onChange`, which `affectsSidebarStructure` flags so the cache rebuilds. +- Folders are pinnable through the same `pinWorktree` / `unpinWorktree` actions as git worktrees. The pin / unpin flow uses `SidebarState.removeAnywhere` + `insert` to enforce the "exactly one bucket" invariant against any pre-state (hand-edit, migrator race) where a row lives in two buckets simultaneously. A hoisted folder is omitted from its `.folder` section entirely; `SidebarStructure` knows not to emit it. +- Auto-dismiss of the highlight onboarding card fires from two places that cover the realistic entry points. (1) The reducer handler for `.sidebarGroupingTogglesChanged` bumps `@Shared(.appStorage("highlightRelevantOnboardingDismissedAt"))` when both grouping toggles end up off; this covers any path that flips a toggle while `SidebarListView` is mounted (the `.onChange` watcher dispatches the action). (2) The menu bindings in `SidebarCommands.groupPinnedRowsToggle` / `groupActiveRowsToggle` fire the same dismiss inside their setter, mirroring `nestWorktreesToggle`, so toggling from the menu bar while the sidebar column is collapsed still dismisses the card. ## Folder (non-git) repositories - `Repository.isGitRepository` classifies each root at load time via `Repository.isGitRepository(at:)`, which approximates git's own `is_git_directory()` check: `.bare` / `.git` root-name shortcut, then `rootURL/.git` existence (worktree root, covers primary / linked / submodule / `--separate-git-dir` layouts), then the `HEAD` + `objects` + `refs` trio at the root — with `HEAD` required to be a regular file (git rejects a `HEAD` directory) — so any git dir is recognized regardless of naming, including bare clones whose directory name does not end in `.git`. Classification runs through the injected `GitClientDependency.isGitRepository` closure so tests can override it without touching the filesystem. - A folder-kind repository has exactly one synthesized "main" `Worktree` with `id = "folder:" + path` (see `Repository.folderWorktreeID(for:)`), `workingDirectory == rootURL`. Selection and terminal binding reuse the standard `SidebarSelection.worktree(id)` machinery — nothing git-specific runs for folders. -- The sidebar renders each folder as its own `Section` with an empty header and a single selectable row. The context menu offers the same entries as a git worktree row, minus pin / archive / "Copy as Branch Name", plus "Folder Settings…" (the section has no header so there is no ellipsis menu). +- The sidebar renders each folder as its own `Section` with an empty header (`header: { EmptyView() }`, kept so `.listStyle(.sidebar)` keeps a visible section break between consecutive folder repos) and a single selectable row. The context menu offers the same entries as a git worktree row, minus archive / "Copy as Branch Name", plus "Folder Settings…" (the section has no header so there is no ellipsis menu). Folders ARE pinnable: a folder synthetic worktree seeds into the `.unpinned` bucket by default and the user can pin / unpin it through the same `pinWorktree` / `unpinWorktree` actions that govern git worktrees. `reconcileSidebarState` skips the `mainID == worktreeID` prune for folder repos so a folder pin survives `.repositoriesLoaded`. The folder row's view path resolves via `Repository.folderWorktreeID(for:)` rather than the `.pinned` bucket so it stays visible across pin / unpin transitions. - The Delete Script for a folder runs through the existing `.requestDeleteSidebarItems` → `.confirmDeleteSidebarItems` → `.deleteSidebarItemConfirmed` → `.deleteScriptCompleted` pipeline; the handlers branch inside so `gitClient.removeWorktree` is never called for a folder and the success path emits `.repositoryRemovalCompleted`, which the batch aggregator drains into a single `.repositoriesRemoved` terminal. `removingRepositoryIDs` is the source of truth for "this is a folder delete" so the intent survives a `git init` happening between confirmation and completion. - Settings hides the Setup and Archive Script sections for folders; Delete Script and user-defined scripts stay. `openRepositorySettings` (context menu + deeplink) routes folders to `.repositoryScripts` because there is no general pane for them. -- `worktreesForInfoWatcher()` filters out folder repositories so the HEAD watcher never probes a non-git path. The command palette renders folder rows as the repo name alone instead of `Foo / Foo`, and worktree deeplinks (`.archive`, `.unarchive`, `.pin`, `.unpin`) reject folder targets with an explanatory alert. +- `worktreesForInfoWatcher()` filters out folder repositories so the HEAD watcher never probes a non-git path. The command palette renders folder rows as the repo name alone instead of `Foo / Foo`, and worktree deeplinks for `.archive` and `.unarchive` reject folder targets with an explanatory alert. `.pin` and `.unpin` flow through the shared bucket machinery and are valid for folders. - Creating new worktrees on a folder is rejected up front in `createRandomWorktreeInRepository` / `createWorktreeInRepository` and in the `.repoWorktreeNew` deeplink handler — the menu / hotkey / palette never reaches `gitClient.createWorktreeStream` for a folder target. ## Scripts (repo + global) diff --git a/supacode/App/SidebarBottomCardView.swift b/supacode/App/SidebarBottomCardView.swift index b47cf9502..3dbc1d595 100644 --- a/supacode/App/SidebarBottomCardView.swift +++ b/supacode/App/SidebarBottomCardView.swift @@ -5,18 +5,19 @@ import SwiftUI /// Mutually-exclusive host for the pinned sidebar bottom card. Priority order: /// 1. Coding-agent updates available / initial install prompt /// (`CodingAgentsSidebarCardView`). -/// 2. Nested-worktrees onboarding prompt (`NestedWorktreesOnboardingCardView`). -/// 3. Nothing. +/// 2. Highlight Relevant onboarding prompt (`HighlightRelevantOnboardingCardView`). +/// 3. Nested-worktrees onboarding prompt (`NestedWorktreesOnboardingCardView`). +/// 4. Nothing. /// /// Owns the `@Shared(.appStorage)` reads as stored properties so SwiftUI /// observes them at this layer and re-renders when the user dismisses a /// card. Each downstream card's `resolveMode(...)` takes the resolved values /// as parameters so they stay pure (no hidden global reads inside a static). /// -/// `nestWorktreesByBranch` is observed here so the visible-card resolver can -/// react to the toggle, but the permadismiss side-effect on toggle-off lives -/// in `SidebarCommands` (where the menu toggle actually fires), so it works -/// regardless of whether the sidebar column is currently visible. +/// Toggles (`nestWorktreesByBranch`, `highlightRelevant`) are observed here so +/// the resolver can react, but the permadismiss side-effects on toggle-off +/// live in `SidebarCommands` (where the menu toggles actually fire), so they +/// work regardless of whether the sidebar column is currently visible. struct SidebarBottomCardView: View { let store: StoreOf @Shared(.appStorage("codingAgentsSetupCardDismissedAt")) @@ -24,16 +25,29 @@ struct SidebarBottomCardView: View { @Shared(.sidebarNestWorktreesByBranch) private var nestWorktreesByBranch: Bool @Shared(.appStorage("nestedWorktreesOnboardingDismissedAt")) private var onboardingDismissedAt: Date = .distantPast + @Shared(.sidebarGroupPinnedRows) private var groupPinnedRows: Bool + @Shared(.sidebarGroupActiveRows) private var groupActiveRows: Bool + @Shared(.appStorage("highlightRelevantOnboardingDismissedAt")) + private var highlightDismissedAt: Date = .distantPast var body: some View { let agentMode = CodingAgentsSidebarCardView.resolveMode( for: store, dismissedAt: agentDismissedAt ) + let highlightMode = HighlightRelevantOnboardingCardView.resolveMode( + groupPinnedRows: groupPinnedRows, + groupActiveRows: groupActiveRows, + dismissedAt: highlightDismissedAt + ) let onboardingMode = NestedWorktreesOnboardingCardView.resolveMode( nestWorktreesByBranch: nestWorktreesByBranch, dismissedAt: onboardingDismissedAt ) - let resolved = Slot.resolve(agentMode: agentMode, onboardingMode: onboardingMode) + let resolved = Slot.resolve( + agentMode: agentMode, + highlightMode: highlightMode, + onboardingMode: onboardingMode + ) Group { switch resolved { case .none: @@ -41,6 +55,9 @@ struct SidebarBottomCardView: View { case .agent(let mode): CodingAgentsSidebarCardView(store: store, mode: mode) .transition(Slot.transition) + case .highlightRelevantOnboarding: + HighlightRelevantOnboardingCardView() + .transition(Slot.transition) case .nestedWorktreesOnboarding: NestedWorktreesOnboardingCardView() .transition(Slot.transition) @@ -55,18 +72,21 @@ struct SidebarBottomCardView: View { enum Slot: Equatable { case none case agent(CodingAgentsSidebarCardView.Mode) + case highlightRelevantOnboarding case nestedWorktreesOnboarding static let transition: AnyTransition = .move(edge: .bottom).combined(with: .opacity) static func resolve( agentMode: CodingAgentsSidebarCardView.Mode, + highlightMode: HighlightRelevantOnboardingCardView.Mode, onboardingMode: NestedWorktreesOnboardingCardView.Mode ) -> Slot { switch agentMode { case .updatesAvailable, .promptInstall: return .agent(agentMode) case .hidden: break } + if highlightMode == .visible { return .highlightRelevantOnboarding } return onboardingMode == .visible ? .nestedWorktreesOnboarding : .none } @@ -81,12 +101,8 @@ struct SidebarBottomCardView: View { case .agent(.updatesAvailable(let agents)): "agent:updates:" + agents.map { String(describing: $0) }.sorted().joined(separator: ",") case .agent(.promptInstall): "agent:promptInstall" - case .agent(.hidden): - // `resolve` collapses `.hidden` to `.none` so this is unreachable in - // production. Returning a stable string keeps the render path - // crash-free if a future caller (e.g. a test or debug surface) - // constructs `.agent(.hidden)` directly. - "agent:hidden" + case .agent(.hidden): "agent:hidden" + case .highlightRelevantOnboarding: "highlightRelevant:visible" case .nestedWorktreesOnboarding: "nestedWorktrees:visible" } } diff --git a/supacode/Commands/SidebarCommands.swift b/supacode/Commands/SidebarCommands.swift index 25d659532..3bd037f60 100644 --- a/supacode/Commands/SidebarCommands.swift +++ b/supacode/Commands/SidebarCommands.swift @@ -6,11 +6,14 @@ struct SidebarCommands: Commands { @FocusedValue(\.toggleLeftSidebarAction) private var toggleLeftSidebarAction @FocusedValue(\.revealInSidebarAction) private var revealInSidebarAction @Shared(.settingsFile) private var settingsFile - @Shared(.appStorage("worktreeRowDisplayMode")) private var displayMode: WorktreeRowDisplayMode = .branchFirst @Shared(.appStorage("worktreeRowHideSubtitleOnMatch")) private var hideSubtitleOnMatch = true @Shared(.sidebarNestWorktreesByBranch) private var nestWorktreesByBranch: Bool @Shared(.appStorage("nestedWorktreesOnboardingDismissedAt")) private var nestedOnboardingDismissedAt: Date = .distantPast + @Shared(.sidebarGroupPinnedRows) private var groupPinnedRows: Bool + @Shared(.sidebarGroupActiveRows) private var groupActiveRows: Bool + @Shared(.appStorage("highlightRelevantOnboardingDismissedAt")) + private var highlightOnboardingDismissedAt: Date = .distantPast /// Binding that pairs the nesting toggle with a permadismiss of the /// onboarding card on transitions to `false`. Lives on the menu command @@ -31,6 +34,38 @@ struct SidebarCommands: Commands { ) } + /// Mirrors `nestWorktreesToggle` so the dismiss also fires when the menu + /// is used while the sidebar column is hidden (no `SidebarListView` body + /// is alive to dispatch `.sidebarGroupingTogglesChanged`). The reducer + /// handler still fires when the sidebar is visible, so this is a + /// belt-and-suspenders pair, not the only trigger. + private var groupPinnedRowsToggle: Binding { + Binding( + get: { groupPinnedRows }, + set: { newValue in + $groupPinnedRows.withLock { $0 = newValue } + dismissHighlightOnboardingIfBothOff() + } + ) + } + + private var groupActiveRowsToggle: Binding { + Binding( + get: { groupActiveRows }, + set: { newValue in + $groupActiveRows.withLock { $0 = newValue } + dismissHighlightOnboardingIfBothOff() + } + ) + } + + private func dismissHighlightOnboardingIfBothOff() { + guard !groupPinnedRows, !groupActiveRows, + !HighlightRelevantOnboardingCardView.isDismissed(at: highlightOnboardingDismissedAt) + else { return } + $highlightOnboardingDismissedAt.withLock { $0 = .now } + } + var body: some Commands { let overrides = settingsFile.global.shortcutOverrides let toggleLeftSidebar = AppShortcuts.toggleLeftSidebar.effective(from: overrides) @@ -49,13 +84,12 @@ struct SidebarCommands: Commands { .help("Reveal in Sidebar (\(revealInSidebar?.display ?? "none"))") .disabled(revealInSidebarAction == nil) Section { - Picker("Title and Subtitle", systemImage: "textformat", selection: Binding($displayMode)) { - ForEach(WorktreeRowDisplayMode.allCases) { mode in - Text(mode.label).tag(mode) - } + Menu("Group Relevant Sidebar Rows") { + Toggle("Group Pinned Rows", isOn: groupPinnedRowsToggle) + Toggle("Group Active Rows", isOn: groupActiveRowsToggle) } - Toggle("Hide Subtitle on Match", isOn: Binding($hideSubtitleOnMatch)) Toggle("Nest Worktrees by Branch", isOn: nestWorktreesToggle) + Toggle("Hide Worktree Name on Match", isOn: Binding($hideSubtitleOnMatch)) } } } diff --git a/supacode/Domain/Repository.swift b/supacode/Domain/Repository.swift index 65f3c4988..92e1ac0f4 100644 --- a/supacode/Domain/Repository.swift +++ b/supacode/Domain/Repository.swift @@ -105,6 +105,17 @@ struct Repository: Identifiable, Hashable, Sendable { worktreeID.hasPrefix(folderWorktreeIDPrefix) } + /// Shared trim + fallback for the sidebar header and the highlight-row tag. + /// Trims `custom`; falls back to `fallback` when the trimmed value is empty. + static func sidebarDisplayName(custom: String?, fallback: String) -> String { + guard let trimmed = custom?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty + else { + return fallback + } + return trimmed + } + static func name(for rootURL: URL) -> String { let name = rootURL.lastPathComponent if name == ".bare" || name == ".git" { diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index e4b61efd1..255f89e61 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -1184,10 +1184,9 @@ struct AppFeature { return .none } // Folders expose the worktree deeplink surface only for the - // actions that actually apply — select, open terminals, delete, - // run scripts. `.archive` / `.unarchive` / `.pin` / `.unpin` - // make no sense for a folder's synthetic main worktree, so - // reject them explicitly rather than silently no-op-ing. + // actions that actually apply. `.archive` / `.unarchive` still + // make no sense for a folder's synthetic main worktree; pin and + // unpin now flow through the shared bucket machinery. if let folderRepoID = state.repositories.repositoryID(for: worktreeID), let folderRepo = state.repositories.repositories[id: folderRepoID], !folderRepo.isGitRepository @@ -1196,8 +1195,6 @@ struct AppFeature { switch action { case .archive: incompatibleAction = .archive case .unarchive: incompatibleAction = .unarchive - case .pin: incompatibleAction = .pin - case .unpin: incompatibleAction = .unpin default: incompatibleAction = nil } if let incompatibleAction { diff --git a/supacode/Features/Repositories/BusinessLogic/SidebarPersistenceKey.swift b/supacode/Features/Repositories/BusinessLogic/SidebarPersistenceKey.swift index ff07ab627..b440d9cf7 100644 --- a/supacode/Features/Repositories/BusinessLogic/SidebarPersistenceKey.swift +++ b/supacode/Features/Repositories/BusinessLogic/SidebarPersistenceKey.swift @@ -153,4 +153,18 @@ nonisolated extension SharedReaderKey where Self == AppStorageKey.Default static var sidebarNestWorktreesByBranch: Self { Self[.appStorage("sidebarNestWorktreesByBranch"), default: true] } + + /// "Group Pinned Rows" view-menu toggle. When on, pinned rows from every + /// repository are hoisted into a single Pinned section at the top of the + /// sidebar. Defaults to on so the feature is discoverable on first launch. + static var sidebarGroupPinnedRows: Self { + Self[.appStorage("sidebarGroupPinnedRows"), default: true] + } + + /// "Group Active Rows" view-menu toggle. When on, rows with unread + /// notifications / agents / awaiting input / running scripts are hoisted + /// into a single Active section at the top of the sidebar. + static var sidebarGroupActiveRows: Self { + Self[.appStorage("sidebarGroupActiveRows"), default: true] + } } diff --git a/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift b/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift new file mode 100644 index 000000000..6d594c9d0 --- /dev/null +++ b/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift @@ -0,0 +1,739 @@ +import ComposableArchitecture +import Dependencies +import Foundation +import OrderedCollections + +/// Dependency switch that gates the reducer's post-reduce sidebar-structure +/// recompute. Defaults `true` everywhere so production, preview, and tests +/// see the same cached structure. See `AGENTS.md` (Sidebar performance) for +/// the canonical TestStore mirror rules. +public nonisolated enum SidebarStructureAutoRecomputeKey: DependencyKey { + public static let liveValue: Bool = true + public static let previewValue: Bool = true + public static let testValue: Bool = true +} + +extension DependencyValues { + public nonisolated var sidebarStructureAutoRecompute: Bool { + get { self[SidebarStructureAutoRecomputeKey.self] } + set { self[SidebarStructureAutoRecomputeKey.self] = newValue } + } +} + +/// Classification buckets for the global Active section. Lower raw value = +/// higher priority. Rows that don't classify into one of the ten buckets are +/// excluded from Active and (when the Pinned section is in play) fall to the +/// bottom of Pinned alphabetically. +enum SidebarActiveClassification: Int, CaseIterable, Comparable, Sendable { + case unreadAwaitingRunning = 1 + case unreadAwaiting = 2 + case unreadAgentRunning = 3 + case unreadAgent = 4 + case unreadRunning = 5 + case awaitingRunning = 6 + case awaiting = 7 + case agentRunning = 8 + case agent = 9 + case running = 10 + + static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue } + + /// Pure classifier driven by four leaf-local flags. Returns `nil` for rows + /// that don't belong in Active (no unread, no awaiting, no agent, no script). + static func classify( + hasUnread: Bool, + hasAwaiting: Bool, + hasAgent: Bool, + hasRunning: Bool + ) -> Self? { + if hasUnread && hasAwaiting && hasRunning { return .unreadAwaitingRunning } + if hasUnread && hasAwaiting { return .unreadAwaiting } + if hasUnread && hasAgent && hasRunning { return .unreadAgentRunning } + if hasUnread && hasAgent { return .unreadAgent } + if hasUnread && hasRunning { return .unreadRunning } + if hasAwaiting && hasRunning { return .awaitingRunning } + if hasAwaiting { return .awaiting } + if hasAgent && hasRunning { return .agentRunning } + if hasAgent { return .agent } + if hasRunning { return .running } + return nil + } + + /// `hasAgent` is keyed off agent badge presence (any tracked instance, + /// including `.idle`) so a row with a visible agent badge surfaces in + /// Active even when the agent isn't actively working; `state.agents` is + /// already empty when badges are disabled by the user. + static func classify(_ state: SidebarItemFeature.State) -> Self? { + classify( + hasUnread: state.hasUnseenNotifications, + hasAwaiting: state.hasAgentAwaitingInput, + hasAgent: !state.agents.isEmpty, + hasRunning: !state.runningScripts.isEmpty + ) + } +} + +/// Pure ordering layer behind the highlight aggregator: priority sort over +/// `SidebarActiveClassification`, alphabetical tie-break. Pinned keeps +/// unclassified rows at the bottom; Active drops them. +enum SidebarHighlightOrdering { + struct Candidate: Equatable, Sendable { + let id: SidebarItemID + let branchName: String + let classification: SidebarActiveClassification? + } + + static func orderedRowIDs( + forPinned: Bool, + candidates: [Candidate] + ) -> [SidebarItemID] { + struct Entry { + let id: SidebarItemID + let priority: Int + let sortKey: String + } + let unclassifiedPriority = SidebarActiveClassification.allCases.count + 1 + var entries: [Entry] = [] + entries.reserveCapacity(candidates.count) + for candidate in candidates { + if forPinned { + let priority = candidate.classification?.rawValue ?? unclassifiedPriority + entries.append(Entry(id: candidate.id, priority: priority, sortKey: candidate.branchName)) + } else { + guard let classification = candidate.classification else { continue } + entries.append( + Entry(id: candidate.id, priority: classification.rawValue, sortKey: candidate.branchName) + ) + } + } + entries.sort { lhs, rhs in + if lhs.priority != rhs.priority { return lhs.priority < rhs.priority } + return lhs.sortKey.localizedCaseInsensitiveCompare(rhs.sortKey) == .orderedAscending + } + return entries.map(\.id) + } +} + +/// Per-repo render plan precomputed by the reducer. Lives here, not in a view +/// file, so the per-repo slot partition / hoisted-row filter / dedupe is a +/// reducer-state derivation (per the "view does zero computation" contract). +struct SidebarItemGroup: Identifiable, Equatable, Sendable { + enum MoveBehavior: Hashable, Sendable { + case disabled + case pinned(Repository.ID) + case unpinned(Repository.ID) + } + + enum Slot: Hashable, Sendable { + case main(isSole: Bool) + case pinnedTail + case pending + case unpinnedTail + } + + let slot: Slot + let repositoryID: Repository.ID + let rowIDs: [SidebarItemID] + + var id: Slot { slot } + + var hideSubtitle: Bool { + if case .main(let isSole) = slot { isSole } else { false } + } + + var moveBehavior: MoveBehavior { + switch slot { + case .main, .pending: .disabled + case .pinnedTail: .pinned(repositoryID) + case .unpinnedTail: .unpinned(repositoryID) + } + } + + /// Only the pinned and unpinned tails participate in branch nesting. + /// The main and pending slots are structural and shouldn't be folded into a tree. + var supportsBranchNesting: Bool { + switch slot { + case .pinnedTail, .unpinnedTail: true + case .main, .pending: false + } + } +} + +/// 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` +/// and does no layout calculation itself. +struct SidebarStructure: Equatable, Sendable { + enum HighlightKind: String, Equatable, Sendable { + case pinned + case active + + var title: String { + switch self { + case .pinned: "Pinned" + case .active: "Active" + } + } + } + + enum Section: Equatable, Sendable, Identifiable { + case highlight(kind: HighlightKind, rowIDs: [Worktree.ID]) + case repository(repositoryID: Repository.ID, groups: [SidebarItemGroup]) + case folder(repositoryID: Repository.ID, rowID: Worktree.ID) + case failedRepository(repositoryID: Repository.ID, rootURL: URL, failureMessage: String) + case placeholder + + var id: SectionID { + switch self { + case .highlight(let kind, _): .highlight(kind) + case .repository(let repositoryID, _): .repository(repositoryID) + case .folder(let repositoryID, _): .folder(repositoryID) + case .failedRepository(let repositoryID, _, _): .failedRepository(repositoryID) + case .placeholder: .placeholder + } + } + + enum SectionID: Hashable, Sendable { + case highlight(HighlightKind) + case repository(Repository.ID) + case folder(Repository.ID) + case failedRepository(Repository.ID) + case placeholder + } + } + + var sections: [Section] + /// Union of every hoisted row across the highlight sections. Per-repo + /// payloads have already filtered against this set; exposed for hotkey + /// consumers and ad-hoc lookups. + var hoistedRowIDs: Set + /// Pre-projected menu slots for `focusedSceneValue(\.visibleHotkeyWorktreeRows, …)`. + var hotkeySlots: [HotkeyWorktreeSlot] + /// Visible top-down position of each hotkey-eligible row, used by the + /// view's `commandKeyObserver`-gated shortcut hint render. + var slotByID: [Worktree.ID: Int] + /// Per-repo color + name payload used to render the `repo · trail` + /// subtitle on highlight rows. Built only for repos that contributed at + /// least one row to the highlight sections. + var repositoryHighlightByID: [Repository.ID: SidebarHighlightRepoTag] + /// 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. + var reorderableRepositoryIDs: [Repository.ID] + + static let empty = SidebarStructure( + sections: [], + hoistedRowIDs: [], + hotkeySlots: [], + slotByID: [:], + repositoryHighlightByID: [:], + reorderableRepositoryIDs: [] + ) + + /// First-frame value used before the reducer recomputes. Surfaces the + /// placeholder section immediately so the sidebar isn't blank during the + /// brief window between `init` and the first `.task` effect. + static let placeholder = SidebarStructure( + sections: [.placeholder], + hoistedRowIDs: [], + hotkeySlots: [], + slotByID: [:], + repositoryHighlightByID: [:], + reorderableRepositoryIDs: [] + ) +} + +extension RepositoriesFeature.State { + /// Equatable-diffs the freshly-built structure against the cached one so a + /// no-op rebuild doesn't invalidate SwiftUI observation. + mutating func recomputeSidebarStructureIfChanged() { + @Shared(.sidebarGroupPinnedRows) var groupPinned + @Shared(.sidebarGroupActiveRows) var groupActive + let new = computeSidebarStructure( + groupPinned: groupPinned, + groupActive: groupActive + ) + if new != sidebarStructure { + sidebarStructure = new + } + } +} + +extension RepositoriesFeature.Action { + /// Coarse predicate naming the actions whose handlers touch + /// `sidebarItems` / `sidebar` buckets / `repositories` / `expandedRepositoryIDs` + /// or any other input `SidebarStructure` reads from. Actions absent here + /// skip the post-reduce recompute entirely (user requirement: don't + /// rebuild on actions that can't affect the visible sidebar). + var affectsSidebarStructure: Bool { + switch self { + // Only the per-leaf actions that mutate fields the structure reads. + case .sidebarItems(.element(id: _, action: let inner)): + return inner.affectsSidebarStructure + case .sidebarGroupingTogglesChanged, .sidebarNestByBranchChanged: + return true + case .repositoriesLoaded, .openRepositoriesFinished, + .repositoryRemovalCompleted, .repositoriesRemoved, + .removeFailedRepository: + return true + case .repositoryExpansionChanged, .branchNestExpansionChanged, + .repositoriesMoved, .pinnedWorktreesMoved, .unpinnedWorktreesMoved: + return true + case .pinWorktree, .unpinWorktree: + return true + case .archiveWorktreeApply, .unarchiveWorktree, + .deleteWorktreeApply, .worktreeDeleted, + .createWorktreeInRepository, .createRandomWorktreeInRepository, + .createRandomWorktreeSucceeded, .createRandomWorktreeFailed, + .pendingWorktreeProgressUpdated, + .archiveScriptCompleted, .deleteScriptCompleted, .scriptCompleted, + .consumeSetupScript, .consumeTerminalFocus, + .autoDeleteExpiredArchivedWorktrees: + return true + case .worktreeBranchNameLoaded, .worktreeLineChangesLoaded, + .worktreeNotificationReceived, .worktreeInfoEvent, + .repositoryPullRequestsLoaded: + return true + // Repo customization save mutates `sidebar.sections[id].title/color`, + // which the highlight-row tag and per-repo color cache both read. + case .repositoryCustomization(.presented(.delegate(.save))): + return true + default: + return false + } + } +} + +extension SidebarItemFeature.Action { + /// Subset of per-leaf actions that mutate fields `SidebarStructure` reads + /// (`lifecycle`, `runningScripts`, `agents`, `hasUnseenNotifications`). + /// Display-only mutations (diff stats, PR refresh, drag/focus/hint flags) + /// don't trigger a recompute. + var affectsSidebarStructure: Bool { + switch self { + case .lifecycleChanged, .runningScriptStarted, .runningScriptStopped, + .agentSnapshotChanged, .terminalProjectionChanged: + return true + case .diffStatsChanged, .pullRequestQueryStarted, .pullRequestChanged, + .shortcutHintChanged, .dragSessionChanged, + .focusTerminalRequested, .focusTerminalConsumed: + return false + } + } +} + +extension RepositoriesFeature.State { + /// Pinned worktree IDs across every repository in the user's repo order. + /// Git main worktrees are excluded (they belong to the per-repo main slot, + /// not the user-curated pinned list). Folders seed into `.unpinned` by + /// default and only appear here after an explicit pin. Archived rows are + /// filtered for parity with the Active candidate filter. The optional + /// `archived` parameter lets a caller share an already-computed set with + /// the aggregator so the O(R) walk runs once per call body, not twice. + func orderedHighlightPinnedIDs(archived: Set? = nil) -> [SidebarItemID] { + let archivedSet = archived ?? archivedWorktreeIDSet + var ids: [SidebarItemID] = [] + for repoID in orderedRepositoryIDs() { + guard let repository = repositories[id: repoID] else { continue } + let isGit = repository.isGitRepository + for worktreeID in sidebar.sections[repoID]?.buckets[.pinned]?.items.keys ?? [] { + if isGit, let worktree = repository.worktrees[id: worktreeID], isMainWorktree(worktree) { + continue + } + if archivedSet.contains(worktreeID) { continue } + ids.append(worktreeID) + } + } + return ids + } + + /// Derive the full sidebar render plan in a single pass. Called by the + /// reducer (see `recomputeSidebarStructure(...)`); never call from a view + /// body or the per-leaf reads here will observation-track every row at + /// the parent and reintroduce the regression commit `0a1ed578` documents. + func computeSidebarStructure( + groupPinned: Bool, + groupActive: Bool + ) -> SidebarStructure { + if !isInitialLoadComplete, repositories.isEmpty { + return SidebarStructure( + sections: [.placeholder], + hoistedRowIDs: [], + hotkeySlots: [], + slotByID: [:], + repositoryHighlightByID: [:], + reorderableRepositoryIDs: [] + ) + } + + let hoists = computeHighlightHoists(groupPinned: groupPinned, groupActive: groupActive) + let repoSections = buildRepositorySections(hoisted: hoists.hoistedSet) + + var sections: [SidebarStructure.Section] = [] + if !hoists.pinned.isEmpty { + sections.append(.highlight(kind: .pinned, rowIDs: hoists.pinned)) + } + if !hoists.active.isEmpty { + sections.append(.highlight(kind: .active, rowIDs: hoists.active)) + } + sections.append(contentsOf: repoSections.sections) + + let hotkey = computeHotkeyOrdering( + pinnedHoisted: hoists.pinned, + activeHoisted: hoists.active, + hoisted: hoists.hoistedSet, + sections: sections + ) + + return SidebarStructure( + sections: sections, + hoistedRowIDs: hoists.hoistedSet, + hotkeySlots: hotkey.slots, + slotByID: hotkey.slotByID, + repositoryHighlightByID: computeRepositoryHighlightTags( + pinnedHoisted: hoists.pinned, + activeHoisted: hoists.active + ), + reorderableRepositoryIDs: repoSections.reorderableRepositoryIDs + ) + } + + /// Hoisted-row payload for a single structure pass. + private struct HighlightHoists { + var pinned: [Worktree.ID] + var active: [Worktree.ID] + var hoistedSet: Set + } + + private func computeHighlightHoists(groupPinned: Bool, groupActive: Bool) -> HighlightHoists { + let archived = archivedWorktreeIDSet + let pinned: [Worktree.ID] + if groupPinned { + let pinnedIDs = orderedHighlightPinnedIDs(archived: archived) + pinned = orderedHighlightCandidates(forPinned: true, candidateIDs: pinnedIDs, excluding: []) + } else { + pinned = [] + } + var hoistedSet: Set = Set(pinned) + + let active: [Worktree.ID] + if groupActive { + let candidateIDs = sidebarItems.ids.filter { id in + guard !archived.contains(id) else { return false } + // Terminating rows already signal their wind-down inline. + return sidebarItems[id: id]?.lifecycle.isTerminating != true + } + active = orderedHighlightCandidates( + forPinned: false, + candidateIDs: Array(candidateIDs), + excluding: hoistedSet + ) + hoistedSet.formUnion(active) + } else { + active = [] + } + return HighlightHoists(pinned: pinned, active: active, hoistedSet: hoistedSet) + } + + /// Per-repo dispatch output. + private struct RepositorySectionsBuild { + var sections: [SidebarStructure.Section] + var reorderableRepositoryIDs: [Repository.ID] + } + + private func buildRepositorySections(hoisted: Set) -> RepositorySectionsBuild { + var sections: [SidebarStructure.Section] = [] + var reorderableRepositoryIDs: [Repository.ID] = [] + let pendingIDsByRepo: [Repository.ID: Set] = Dictionary( + grouping: pendingWorktrees, + by: \.repositoryID + ).mapValues { Set($0.map(\.id)) } + + for rootURL in orderedRepositoryRoots() { + let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) + if let failureMessage = loadFailuresByID[repositoryID] { + sections.append( + .failedRepository( + repositoryID: repositoryID, + rootURL: rootURL, + failureMessage: failureMessage + ) + ) + reorderableRepositoryIDs.append(repositoryID) + continue + } + guard let repository = repositories[id: repositoryID] else { continue } + reorderableRepositoryIDs.append(repositoryID) + if !repository.isGitRepository { + let folderRowID = Repository.folderWorktreeID(for: repository.rootURL) + if !hoisted.contains(folderRowID) { + sections.append(.folder(repositoryID: repositoryID, rowID: folderRowID)) + } + continue + } + let groups = SidebarItemGroup.computeSlots( + in: self, + repositoryID: repositoryID, + pendingIDs: pendingIDsByRepo[repositoryID] ?? [], + hoistedRowIDs: hoisted, + nestWorktreesByBranch: sidebarNestWorktreesByBranch && repository.isGitRepository + ) + sections.append(.repository(repositoryID: repositoryID, groups: groups)) + } + return RepositorySectionsBuild(sections: sections, reorderableRepositoryIDs: reorderableRepositoryIDs) + } + + /// Hotkey assignment output for a single structure pass. + private struct HotkeyOrdering { + var slots: [HotkeyWorktreeSlot] + var slotByID: [Worktree.ID: Int] + } + + private func computeHotkeyOrdering( + pinnedHoisted: [Worktree.ID], + activeHoisted: [Worktree.ID], + hoisted: Set, + sections: [SidebarStructure.Section] + ) -> HotkeyOrdering { + let perRepoVisibleIDs = hotkeyEligibleIDs(in: sections) + var order: [Worktree.ID] = [] + order.reserveCapacity(pinnedHoisted.count + activeHoisted.count + perRepoVisibleIDs.count) + order.append(contentsOf: pinnedHoisted) + order.append(contentsOf: activeHoisted) + for id in perRepoVisibleIDs where !hoisted.contains(id) { + order.append(id) + } + var slotByID: [Worktree.ID: Int] = [:] + slotByID.reserveCapacity(order.count) + for (index, id) in order.enumerated() { + slotByID[id] = index + } + return HotkeyOrdering(slots: hotkeyWorktreeSlots(for: order), slotByID: slotByID) + } + + private func computeRepositoryHighlightTags( + pinnedHoisted: [Worktree.ID], + activeHoisted: [Worktree.ID] + ) -> [Repository.ID: SidebarHighlightRepoTag] { + guard !pinnedHoisted.isEmpty || !activeHoisted.isEmpty else { return [:] } + var contributingRepoIDs: Set = [] + for id in pinnedHoisted { + if let repoID = sidebarItems[id: id]?.repositoryID { + contributingRepoIDs.insert(repoID) + } + } + for id in activeHoisted { + if let repoID = sidebarItems[id: id]?.repositoryID { + contributingRepoIDs.insert(repoID) + } + } + var tags: [Repository.ID: SidebarHighlightRepoTag] = [:] + for repoID in contributingRepoIDs { + guard let repository = repositories[id: repoID] else { continue } + let section = sidebar.sections[repoID] + tags[repoID] = SidebarHighlightRepoTag( + repoName: Repository.sidebarDisplayName(custom: section?.title, fallback: repository.name), + repoColor: section?.color + ) + } + return tags + } + + /// Walk the freshly-built sections to extract visible per-repo row IDs in + /// the same top-down order the user sees them. Skips group headers (only + /// leaves get hotkeys) and falls back to `orderedSidebarItemIDs` for repo + /// sections where branch nesting hides some rows inside collapsed groups. + private func hotkeyEligibleIDs(in sections: [SidebarStructure.Section]) -> [Worktree.ID] { + let expandedRepoIDs = expandedRepositoryIDs + let nestingFilter = orderedSidebarItemIDs(includingRepositoryIDs: expandedRepoIDs) + let visibleSet = Set(nestingFilter) + var ids: [Worktree.ID] = [] + for section in sections { + switch section { + case .highlight, .placeholder, .failedRepository: + continue + case .folder(_, let rowID): + ids.append(rowID) + case .repository(let repositoryID, let groups): + guard expandedRepoIDs.contains(repositoryID) else { continue } + for group in groups { + for rowID in group.rowIDs where visibleSet.contains(rowID) { + ids.append(rowID) + } + } + } + } + return ids + } + + /// Materialize candidates by reading branchName + classification flags + /// from each leaf, then delegate to the pure `SidebarHighlightOrdering` + /// sorter. + private func orderedHighlightCandidates( + forPinned: Bool, + candidateIDs: [SidebarItemID], + excluding: Set + ) -> [Worktree.ID] { + var candidates: [SidebarHighlightOrdering.Candidate] = [] + candidates.reserveCapacity(candidateIDs.count) + for id in candidateIDs { + if excluding.contains(id) { continue } + guard let state = sidebarItems[id: id] else { continue } + candidates.append( + SidebarHighlightOrdering.Candidate( + id: id, + branchName: state.branchName, + classification: SidebarActiveClassification.classify(state) + ) + ) + } + return SidebarHighlightOrdering.orderedRowIDs(forPinned: forPinned, candidates: candidates) + } +} + +extension SidebarItemGroup { + /// Split one repo's bucketed item IDs into the four ordered slots the + /// sidebar renders (`main`, `pinnedTail`, `pending`, `unpinnedTail`), then + /// filter against `hoistedRowIDs` and dedupe across slots via a seen-set + /// so a row that survived a pre-existing double-bucket pre-state renders + /// in at most one position (priority order: main > pinnedTail > pending > + /// unpinnedTail). + /// + /// `nestWorktreesByBranch` should be the effective per-repo value + /// (`@Shared(.sidebarNestWorktreesByBranch)` gated on `isGitRepository`). + /// When set, the pinned and unpinned tails are sorted by branch name + /// (case-insensitive) to match `SidebarBranchNesting.buildRows`, so the + /// hotkey / arrow projection that walks `rowIDs` sees the same top-down + /// order the view renders. Main and pending slots stay in bucket order + /// (they don't participate in branch nesting). + static func computeSlots( + in state: RepositoriesFeature.State, + repositoryID: Repository.ID, + pendingIDs: Set, + hoistedRowIDs: Set, + nestWorktreesByBranch: Bool + ) -> [SidebarItemGroup] { + guard let bucket = state.sidebarGrouping.bucketsByRepository[repositoryID] else { return [] } + let pinnedRows = bucket[.pinned] + let unpinnedRows = bucket[.unpinned] + + // Scan the whole pinned bucket: rebuild seeds main at index 0, but a + // corrupted persisted `.pinned` (hand-edit, migrator race) may surface + // main at a non-zero position. Matching `orderedPinnedWorktreeIDs`'s + // any-position filter keeps `pinnedTail` and the reducer's source list + // in agreement for `translateFilteredMove`. + let rawMainID: SidebarItemID? = pinnedRows.first(where: { id in + state.sidebarItems[id: id]?.isMainWorktree == true + }) + + var seen: Set = [] + var mainID: SidebarItemID? + if let rawMainID { + seen.insert(rawMainID) + if !hoistedRowIDs.contains(rawMainID) { mainID = rawMainID } + } + + var rawPinnedTail: [SidebarItemID] = [] + for id in pinnedRows where id != rawMainID && !seen.contains(id) { + rawPinnedTail.append(id) + seen.insert(id) + } + var rawPendingTail: [SidebarItemID] = [] + for id in unpinnedRows where pendingIDs.contains(id) && !seen.contains(id) { + rawPendingTail.append(id) + seen.insert(id) + } + var rawUnpinnedTail: [SidebarItemID] = [] + for id in unpinnedRows where !pendingIDs.contains(id) && !seen.contains(id) { + rawUnpinnedTail.append(id) + seen.insert(id) + } + + var pinnedTail = rawPinnedTail.filter { !hoistedRowIDs.contains($0) } + let pendingTail = rawPendingTail.filter { !hoistedRowIDs.contains($0) } + var unpinnedTail = rawUnpinnedTail.filter { !hoistedRowIDs.contains($0) } + + if nestWorktreesByBranch { + pinnedTail = sortedByBranchName(pinnedTail, in: state) + unpinnedTail = sortedByBranchName(unpinnedTail, in: state) + } + + let isSoleDefaultWorktree = + mainID != nil && pinnedTail.isEmpty && pendingTail.isEmpty && unpinnedTail.isEmpty + + return [ + SidebarItemGroup( + slot: .main(isSole: isSoleDefaultWorktree), + repositoryID: repositoryID, + rowIDs: mainID.map { [$0] } ?? [] + ), + SidebarItemGroup( + slot: .pinnedTail, + repositoryID: repositoryID, + rowIDs: pinnedTail + ), + SidebarItemGroup( + slot: .pending, + repositoryID: repositoryID, + rowIDs: pendingTail + ), + SidebarItemGroup( + slot: .unpinnedTail, + repositoryID: repositoryID, + rowIDs: unpinnedTail + ), + ] + } + + /// Case-insensitive sort by `branchName`, matching `SidebarBranchNesting.buildRows`. + /// Fallback to the row id keeps a transient missing leaf from breaking sort + /// stability rather than crashing. + private static func sortedByBranchName( + _ ids: [SidebarItemID], + in state: RepositoriesFeature.State + ) -> [SidebarItemID] { + ids.sorted { lhs, rhs in + let lhsName = state.sidebarItems[id: lhs]?.branchName ?? lhs + let rhsName = state.sidebarItems[id: rhs]?.branchName ?? rhs + return lhsName.localizedCaseInsensitiveCompare(rhsName) == .orderedAscending + } + } + + /// SwiftUI emits `.onMove` offsets/destination against the *visible* rows + /// (the post-hoisting filter). The reducer's `pinnedWorktreesMoved` / + /// `unpinnedWorktreesMoved` mutates the *full* bucket. Translate visible + /// indices to full-bucket indices before dispatching so a reorder inside a + /// bucket with hoisted rows lands the dragged row at the visible target + /// without disturbing hoisted siblings' relative positions. + /// + /// Returns `nil` if the inputs disagree (visible id not present in full, + /// or out-of-range offset / destination); the caller should drop the move. + static func translateFilteredMove( + offsets: IndexSet, + destination: Int, + visibleIDs: [Worktree.ID], + fullIDs: [Worktree.ID] + ) -> (offsets: IndexSet, destination: Int)? { + guard destination >= 0, destination <= visibleIDs.count else { return nil } + var fullIndexByID: [Worktree.ID: Int] = [:] + fullIndexByID.reserveCapacity(fullIDs.count) + for (index, id) in fullIDs.enumerated() { fullIndexByID[id] = index } + + var translatedOffsets = IndexSet() + for visibleIndex in offsets { + guard visibleIDs.indices.contains(visibleIndex) else { return nil } + guard let fullIndex = fullIndexByID[visibleIDs[visibleIndex]] else { return nil } + translatedOffsets.insert(fullIndex) + } + + let translatedDestination: Int + if destination == visibleIDs.count { + translatedDestination = fullIDs.count + } else if let fullIndex = fullIndexByID[visibleIDs[destination]] { + translatedDestination = fullIndex + } else { + return nil + } + return (translatedOffsets, translatedDestination) + } +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+Removal.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+Removal.swift index f6e9b600e..d7fe0c85f 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+Removal.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+Removal.swift @@ -131,21 +131,17 @@ extension RepositoriesFeature { /// folder row (hotkey, deeplink). Drives /// `folderIncompatibleAlert` so every entry point presents the /// same precise copy ("Archive only applies to git repositories.") - /// instead of a generic "Action not available." Shared across - /// this feature's hotkey handlers AND the `AppFeature` deeplink - /// layer so the copy can't drift between entry points. + /// instead of a generic "Action not available." Shared between + /// this feature's hotkey handlers and the `AppFeature` deeplink + /// layer so the copy can't drift. enum FolderIncompatibleAction: Equatable, Sendable { case archive case unarchive - case pin - case unpin var displayName: String { switch self { case .archive: "Archive" case .unarchive: "Unarchive" - case .pin: "Pin" - case .unpin: "Unpin" } } @@ -191,11 +187,11 @@ extension RepositoriesFeature { repositoryID, outcome: .failureSilent, selectionWasRemoved: false)) } - /// Shared "Action not available" alert shown when a git-only - /// action (archive / pin / unpin) is dispatched against a - /// folder repository. Four call sites produced the same - /// `AlertState` inline before this helper existed — now they - /// share one construction so the copy can't drift. + /// Shared "Action not available" alert shown when archive / + /// unarchive is dispatched against a folder repository. Multiple + /// call sites produced the same `AlertState` inline before this + /// helper existed; now they share one construction so the copy + /// can't drift. func folderIncompatibleAlert(action: FolderIncompatibleAction) -> AlertState { let copy = action.alertCopy return messageAlert(title: copy.title, message: copy.message) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 009359c21..9631dc1a5 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -148,6 +148,12 @@ struct RepositoriesFeature { /// State so the reducer's hotkey / arrow navigation walks the same /// trie-filtered row list the sidebar actually renders. @Shared(.sidebarNestWorktreesByBranch) var sidebarNestWorktreesByBranch: Bool + /// Single source of truth the sidebar view renders against. Recomputed + /// inside the reducer (see `recomputeSidebarStructureIfChanged()`) so + /// `SidebarListView.body` is a dumb iterator. The Equatable diff guard + /// in the recompute helper keeps a no-op rebuild from invalidating + /// SwiftUI when the user-visible layout didn't actually change. + var sidebarStructure: SidebarStructure = .placeholder @Presents var worktreeCreationPrompt: WorktreeCreationPromptFeature.State? @Presents var repositoryCustomization: RepositoryCustomizationFeature.State? @Presents var alert: AlertState? @@ -203,6 +209,19 @@ struct RepositoriesFeature { enum Action { case sidebarItems(IdentifiedActionOf) case task + /// Fired by `SidebarListView.onChange` whenever `@Shared(.sidebarGroupPinnedRows)` + /// or `@Shared(.sidebarGroupActiveRows)` mutates while the sidebar is mounted. + /// The post-reduce hook picks up the new toggle state and rebuilds the cached + /// structure; the explicit handler also fires the highlight-onboarding + /// auto-dismiss. Toggling from the menu while the sidebar column is collapsed + /// bypasses this action; the matching dismiss in `SidebarCommands` setters + /// covers that path. + case sidebarGroupingTogglesChanged + /// Fired by `SidebarListView.onChange` whenever `@Shared(.sidebarNestWorktreesByBranch)` + /// mutates. Triggers a structure recompute so the alphabetical per-bucket + /// sort that nesting forces shows up in `slotByID` / `hotkeySlots` (which + /// the view reads to assign ⌃1..⌃0 hotkeys). + case sidebarNestByBranchChanged case setOpenPanelPresented(Bool) case loadPersistedRepositories case refreshWorktrees @@ -427,6 +446,28 @@ struct RepositoriesFeature { state.shouldRestoreLastFocusedWorktree = state.sidebar.focusedWorktreeID != nil return .send(.loadPersistedRepositories) + case .sidebarGroupingTogglesChanged: + // The post-reduce hook below picks up the toggle state and rebuilds. + // Auto-dismiss the highlight onboarding card when both toggles end up + // off; the `SidebarCommands` menu setters fire the same dismiss so + // toggling while the sidebar column is collapsed is also covered. + @Shared(.sidebarGroupPinnedRows) var groupPinned + @Shared(.sidebarGroupActiveRows) var groupActive + if !groupPinned, !groupActive { + @Shared(.appStorage("highlightRelevantOnboardingDismissedAt")) + var dismissedAt: Date = .distantPast + if !HighlightRelevantOnboardingCardView.isDismissed(at: dismissedAt) { + $dismissedAt.withLock { $0 = now } + } + } + return .none + + case .sidebarNestByBranchChanged: + // No-op handler: the post-reduce hook reads `sidebarNestWorktreesByBranch` + // and rebuilds `sidebarStructure` so the alphabetical per-bucket sort + // lands in `slotByID` / `hotkeySlots`. + return .none + case .setOpenPanelPresented(let isPresented): state.isOpenPanelPresented = isPresented return .none @@ -2362,37 +2403,36 @@ struct RepositoriesFeature { ) case .pinWorktree(let worktreeID): - // Main worktrees never appear in any sidebar bucket (the - // seed pass skips them), so pinning one is a no-op. + // Git "main" worktrees never appear in any sidebar bucket (the + // seed pass skips them), so pinning one is a no-op. Folder + // synthetic worktrees satisfy `isMainWorktree` by geometry but + // ARE pinnable; scope the skip to git repos so folders fall + // through to the bucket machinery below. guard let worktree = state.worktree(for: worktreeID), let repositoryID = state.repositoryID(containing: worktreeID), let repository = state.repositories[id: repositoryID] else { return .none } - // Folder-synthetic worktrees pass `isMainWorktree` by - // geometry. Surface the deeplink-equivalent alert instead - // of silently no-op-ing for folders; for git mains the - // silent skip is still correct (main-worktree pinning is - // invalid by design). - if !repository.isGitRepository { - state.alert = folderIncompatibleAlert(action: .pin) - return .none - } - if state.isMainWorktree(worktree) { + if repository.isGitRepository, state.isMainWorktree(worktree) { return .none } + // Pin / unpin are unarchive-adjacent (the new bucket flow drops + // `archivedAt` via `removeAnywhere` + `insert`). Refuse to pin + // an archived row so a deeplink or programmatic dispatch can't + // silently resurrect it; the user must unarchive first. + if state.isWorktreeArchived(worktreeID) { return .none } analyticsClient.capture("worktree_pinned", nil) state.$sidebar.withLock { sidebar in - // The seed invariant puts every non-main worktree into - // either `.pinned` or `.unpinned`. A second click on an - // already-pinned row reorders it to the top. - let from = sidebar.currentBucket(of: worktreeID, in: repositoryID) ?? .unpinned - sidebar.move( + // `removeAnywhere` + `insert` enforces the "exactly one bucket" + // invariant against pre-states that have the row in `.pinned` and + // `.unpinned` simultaneously (hand-edit, migrator race) and also + // handles the not-bucketed case (folders before first reconcile). + sidebar.removeAnywhere(worktree: worktreeID, in: repositoryID) + sidebar.insert( worktree: worktreeID, in: repositoryID, - from: from, - to: .pinned, + bucket: .pinned, position: 0 ) } @@ -2401,21 +2441,23 @@ struct RepositoriesFeature { case .unpinWorktree(let worktreeID): guard let repositoryID = state.repositoryID(containing: worktreeID), - let repository = state.repositories[id: repositoryID] + state.repositories[id: repositoryID] != nil else { return .none } - if !repository.isGitRepository { - state.alert = folderIncompatibleAlert(action: .unpin) - return .none - } + // Mirrors the `pinWorktree` archive guard: don't let an archived + // row trip through the bucket machinery and lose its `archivedAt` + // timestamp as a side effect. + if state.isWorktreeArchived(worktreeID) { return .none } analyticsClient.capture("worktree_unpinned", nil) state.$sidebar.withLock { sidebar in - sidebar.move( + // Same invariant as `pinWorktree`: collapse any pre-existing + // bucket placement into a single `.unpinned` entry. + sidebar.removeAnywhere(worktree: worktreeID, in: repositoryID) + sidebar.insert( worktree: worktreeID, in: repositoryID, - from: .pinned, - to: .unpinned, + bucket: .unpinned, position: 0 ) } @@ -3252,6 +3294,19 @@ struct RepositoriesFeature { .ifLet(\.$repositoryCustomization, action: \.repositoryCustomization) { RepositoryCustomizationFeature() } + // Targeted post-reduce hook: only the actions that demonstrably touch + // structure inputs trigger a recompute. The Equatable diff inside the + // helper suppresses no-op rebuilds at the SwiftUI layer. Gated on + // `\.sidebarStructureAutoRecompute` (defaults to true everywhere); a few + // legacy tests that don't care about sidebar layout opt out via + // `withDependencies`. + Reduce { state, action in + @Dependency(\.sidebarStructureAutoRecompute) var autoRecompute + if autoRecompute, action.affectsSidebarStructure { + state.recomputeSidebarStructureIfChanged() + } + return .none + } } private func refreshRepositoryPullRequests( @@ -3665,15 +3720,23 @@ extension RepositoriesFeature.State { } func worktreeID(byOffset offset: Int) -> Worktree.ID? { - // Walk the same ordered list Cmd+1..9 binds to, so arrow navigation and slot - // selection agree with what the sidebar shows (pinned, pending, non-pending). - let ids = orderedSidebarItemIDs(includingRepositoryIDs: expandedRepositoryIDs) + // Walk the structure's `hotkeySlots`, which already reflects the + // visible top-down order (hoisted Pinned + Active first, then per-repo + // with hoisted rows filtered out, with the nest-by-branch alphabetical + // sort applied). Arrow navigation, ⌃1..⌃0 hotkeys, and the menu-bar + // slot picker all bind to the same visual ordering. The post-reduce + // hook keeps the cache fresh for every structure-affecting action, + // including in tests, so reading the cache here is always live. + let ids = sidebarStructure.hotkeySlots.map(\.id) guard !ids.isEmpty else { return nil } if let currentID = selectedWorktreeID, let currentIndex = ids.firstIndex(of: currentID) { return ids[(currentIndex + offset + ids.count) % ids.count] } // Selection hidden behind a collapsed group: land on the nearest visible // neighbor in the direction of travel rather than jumping top / bottom. + // The unfiltered anchor list intentionally walks the per-repo bucket + // order (collapsed groups expanded) since hoisted rows are always + // visible and therefore never fall through to this branch. if let currentID = selectedWorktreeID, let anchor = hiddenSelectionAnchor(currentID: currentID, visibleIDs: ids), let neighbor = nearestVisibleNeighbor( @@ -4074,8 +4137,8 @@ extension RepositoriesFeature.State { guard useNesting, !rowIDs.isEmpty else { return rowIDs } let collapsedPrefixes: Set = ignoreCollapsedGroups - ? [] - : sidebar.sections[repositoryID]?.buckets[bucket]?.collapsedBranchPrefixes ?? [] + ? [] + : sidebar.sections[repositoryID]?.buckets[bucket]?.collapsedBranchPrefixes ?? [] // `uniquingKeysWith` so a transient duplicate row ID can't crash the hotkey path. let branchNames = Dictionary( rowIDs.compactMap { id -> (SidebarItemID, String)? in @@ -4102,7 +4165,16 @@ extension RepositoriesFeature.State { /// across PR / lifecycle ticks. Lets `focusedSceneValue` dedupe so open submenus /// don't rebuild and drop hover. func hotkeyWorktreeSlots(includingRepositoryIDs: Set) -> [HotkeyWorktreeSlot] { - orderedSidebarItemIDs(includingRepositoryIDs: includingRepositoryIDs).compactMap { id in + hotkeyWorktreeSlots( + for: orderedSidebarItemIDs(includingRepositoryIDs: includingRepositoryIDs) + ) + } + + /// Project a caller-provided ID list into menu slots. Used when the sidebar + /// has composed an order the reducer can't derive on its own (e.g. highlight + /// sections hoisted above per-repo rows). + func hotkeyWorktreeSlots(for ids: [Worktree.ID]) -> [HotkeyWorktreeSlot] { + ids.compactMap { id in guard let item = sidebarItems[id: id] else { return nil } return HotkeyWorktreeSlot(id: item.id, name: item.name, repositoryID: item.repositoryID) } @@ -4576,7 +4648,11 @@ extension RepositoriesFeature.State { rebuilt[repoID] = section continue } - let mainID = repository.worktrees.first(where: { isMainWorktree($0) })?.id + // Folder synthetic worktrees satisfy `isMainWorktree` by geometry but are + // user-pinnable. Scope the main-worktree skip to git repos so a pin on a + // folder survives `.repositoriesLoaded`. + let mainID = + repository.isGitRepository ? repository.worktrees.first(where: { isMainWorktree($0) })?.id : nil let worktreeIDs = Set(repository.worktrees.map(\.id)) var copy = section var seenInCuratedBuckets: Set = [] @@ -4584,7 +4660,7 @@ extension RepositoriesFeature.State { if bucketID == .archived { continue } var prunedItems: OrderedDictionary = [:] for (worktreeID, item) in bucket.items { - if worktreeID == mainID { continue } + if let mainID, worktreeID == mainID { continue } if pruneLivenessAgainstRoster, !worktreeIDs.contains(worktreeID) { continue } prunedItems[worktreeID] = item seenInCuratedBuckets.insert(worktreeID) @@ -4600,7 +4676,7 @@ extension RepositoriesFeature.State { // Seed every live non-main worktree that isn't already curated. Mutation // actions assume every live worktree has a bucket and skip fallback paths. for worktree in repository.worktrees { - if worktree.id == mainID { continue } + if let mainID, worktree.id == mainID { continue } if seenInCuratedBuckets.contains(worktree.id) || archivedIDs.contains(worktree.id) { continue } var unpinned = copy.buckets[.unpinned] ?? .init() unpinned.items[worktree.id] = .init() diff --git a/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift b/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift index 843a12526..bdf14ccf4 100644 --- a/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift +++ b/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift @@ -52,6 +52,16 @@ struct SidebarItemFeature { case archiving case deletingScript case deleting + + /// True for the wind-down states that should drop out of the Active + /// rail. `.pending` stays eligible: a row running its setup script is + /// exactly what Active is meant to surface. + var isTerminating: Bool { + switch self { + case .archiving, .deletingScript, .deleting: return true + case .idle, .pending: return false + } + } } var addedLines: Int? @@ -208,6 +218,9 @@ extension SidebarItemFeature.State { if isPinned { return .pinned } return .default } + /// True iff any tracked agent on this row is awaiting user input. + /// Drives the Active section's classification ("agent awaiting input"). + var hasAgentAwaitingInput: Bool { agents.contains(where: \.awaitingInput) } } extension SidebarItemFeature.State.Lifecycle { diff --git a/supacode/Features/Repositories/Views/HighlightRelevantOnboardingCardView.swift b/supacode/Features/Repositories/Views/HighlightRelevantOnboardingCardView.swift new file mode 100644 index 000000000..262e658fe --- /dev/null +++ b/supacode/Features/Repositories/Views/HighlightRelevantOnboardingCardView.swift @@ -0,0 +1,77 @@ +import Sharing +import SwiftUI + +/// Bottom-of-sidebar onboarding card surfacing the new "Highlight Relevant +/// Sidebar Items" feature. Renders while the toggle is on and the user +/// hasn't dismissed past the relevance date; the priority host +/// (`SidebarBottomCardView`) owns the AppStorage reads so SwiftUI re-renders +/// at that layer when state changes. +/// +/// Sits above the nested-worktree onboarding card in the priority chain so a +/// fresh install learns about Pinned / Active first. +struct HighlightRelevantOnboardingCardView: View { + /// Bump on each material content change. Users who dismissed before this + /// date see the prompt again. Must be on or before the ship date so a + /// dismiss on the day of release satisfies `dismissedAt >= relevantSince` + /// and the card stays hidden. + static let cardRelevantSinceDate = Date(timeIntervalSince1970: 1_778_889_600) // 2026-05-16. + + static func isDismissed(at dismissedAt: Date) -> Bool { + SidebarCardRelevance.isDismissed(at: dismissedAt, relevantSince: cardRelevantSinceDate) + } + + /// Pure resolver. Visible while either grouping toggle is on and the user + /// hasn't dismissed past the relevance cutoff. The caller owns the + /// AppStorage reads, keeping this resolver free of hidden global reads and + /// SwiftUI re-rendering at the priority-host layer. + static func resolveMode( + groupPinnedRows: Bool, + groupActiveRows: Bool, + dismissedAt: Date + ) -> Mode { + let anyOn = groupPinnedRows || groupActiveRows + return anyOn && !Self.isDismissed(at: dismissedAt) ? .visible : .hidden + } + + var body: some View { + HighlightRelevantOnboardingCardBody() + } + + enum Mode: Equatable { + case hidden + case visible + } +} + +private struct HighlightRelevantOnboardingCardBody: View { + @Shared(.appStorage("highlightRelevantOnboardingDismissedAt")) + private var dismissedAt: Date = .distantPast + + var body: some View { + SidebarCard( + onDismiss: { $dismissedAt.withLock { $0 = .now } }, + content: { + VStack(alignment: .leading, spacing: 4) { + SidebarCardLabel(title: "Pinned and Active at a glance", description: description) + Text("Toggle in View → Group Relevant Sidebar Rows") + .font(.caption2) + .foregroundStyle(.tertiary) + .padding(.top, 2) + } + }, + header: { + Image(systemName: "sparkles") + .font(.title2) + .foregroundStyle(.orange) + .accessibilityHidden(true) + } + ) + } + + private var description: LocalizedStringKey { + """ + Pinned worktrees float to the top, and rows with unread notifications, \ + agents awaiting input, or running scripts surface in a new Active section. + """ + } +} diff --git a/supacode/Features/Repositories/Views/RepoSectionHeaderView.swift b/supacode/Features/Repositories/Views/RepoSectionHeaderView.swift index 1e8081e3d..d6c837a46 100644 --- a/supacode/Features/Repositories/Views/RepoSectionHeaderView.swift +++ b/supacode/Features/Repositories/Views/RepoSectionHeaderView.swift @@ -8,12 +8,7 @@ struct RepoSectionHeaderView: View { let isRemoving: Bool private var displayName: String { - guard let trimmed = customTitle?.trimmingCharacters(in: .whitespacesAndNewlines), - !trimmed.isEmpty - else { - return name - } - return trimmed + Repository.sidebarDisplayName(custom: customTitle, fallback: name) } var body: some View { diff --git a/supacode/Features/Repositories/Views/SidebarHighlightSectionsView.swift b/supacode/Features/Repositories/Views/SidebarHighlightSectionsView.swift new file mode 100644 index 000000000..b258d90bf --- /dev/null +++ b/supacode/Features/Repositories/Views/SidebarHighlightSectionsView.swift @@ -0,0 +1,91 @@ +import ComposableArchitecture +import SupacodeSettingsShared +import SwiftUI + +/// Pinned / Active highlight section renderer. Receives an already-ordered +/// row ID list from `SidebarStructure` and just lays it out; no per-leaf +/// classification or sort runs here. +struct SidebarHighlightSection: View { + let kind: SidebarStructure.HighlightKind + let rowIDs: [Worktree.ID] + let store: StoreOf + let terminalManager: WorktreeTerminalManager + let selectedWorktreeIDs: Set + let repositoryHighlightByID: [Repository.ID: SidebarHighlightRepoTag] + /// Hint string to render in the row's trailing slot, keyed by `Worktree.ID`. + /// Empty when Cmd isn't pressed; the caller builds it once for the whole + /// composed hotkey order. + let shortcutHintByID: [Worktree.ID: String] + + var body: some View { + Section { + ForEach(rowIDs, id: \.self) { rowID in + SidebarHighlightRow( + rowID: rowID, + store: store, + terminalManager: terminalManager, + selectedWorktreeIDs: selectedWorktreeIDs, + repositoryHighlightByID: repositoryHighlightByID, + shortcutHint: shortcutHintByID[rowID] + ) + } + } header: { + HStack(spacing: 4) { + Text(kind.title) + SidebarHighlightHeaderDot(color: kind.indicatorColor) + } + } + } +} + +extension SidebarStructure.HighlightKind { + fileprivate var indicatorColor: Color { + switch self { + case .pinned: .orange + case .active: .blue + } + } +} + +private struct SidebarHighlightHeaderDot: View { + let color: Color + @Environment(\.pixelLength) private var pixelLength + + var body: some View { + Circle() + .fill(color.opacity(0.6)) + .overlay(Circle().stroke(color, lineWidth: pixelLength)) + .frame(width: 6, height: 6) + .accessibilityHidden(true) + } +} + +/// Single highlight-section row. Resolves its repo identity via per-leaf +/// scope so observation stays bounded to the leaf, then forwards into +/// `SidebarItemRow` for the actual draw. Extracted as a struct so each row +/// gets its own SwiftUI identity (per "view subviews as structs"). +private struct SidebarHighlightRow: View { + let rowID: SidebarItemID + @Bindable var store: StoreOf + let terminalManager: WorktreeTerminalManager + let selectedWorktreeIDs: Set + let repositoryHighlightByID: [Repository.ID: SidebarHighlightRepoTag] + let shortcutHint: String? + + var body: some View { + let highlight = + store.scope(state: \.sidebarItems[id: rowID], action: \.sidebarItems[id: rowID]) + .flatMap { repositoryHighlightByID[$0.state.repositoryID] } + SidebarItemRow( + rowID: rowID, + store: store, + terminalManager: terminalManager, + selectedWorktreeIDs: selectedWorktreeIDs, + isRepositoryRemoving: false, + hideSubtitle: false, + moveMode: .alwaysDisabled, + shortcutHint: shortcutHint, + highlightSubtitle: highlight + ) + } +} diff --git a/supacode/Features/Repositories/Views/SidebarItemView.swift b/supacode/Features/Repositories/Views/SidebarItemView.swift index 24d563575..378891c02 100644 --- a/supacode/Features/Repositories/Views/SidebarItemView.swift +++ b/supacode/Features/Repositories/Views/SidebarItemView.swift @@ -9,9 +9,16 @@ enum SidebarNestLayout { static let indentStep: CGFloat = 14 } +/// Repo identity carried alongside a sidebar row so the highlight sections +/// can render a colored `repo · worktree` subtitle that mirrors the window +/// toolbar. `nil` on a row keeps the standard per-repo subtitle. +struct SidebarHighlightRepoTag: Equatable, Hashable, Sendable { + let repoName: String + let repoColor: RepositoryColor? +} + struct SidebarItemView: View { let store: StoreOf - let displayMode: WorktreeRowDisplayMode let hideSubtitle: Bool let hideSubtitleOnMatch: Bool let showsPullRequestInfo: Bool @@ -23,6 +30,8 @@ struct SidebarItemView: View { /// Number of group-header ancestors above this row, used by the renderer /// to apply a per-level leading indent. `0` keeps the existing baseline. var nestDepth: Int = 0 + /// Non-nil only inside the global Pinned / Active sections. + var highlightSubtitle: SidebarHighlightRepoTag? var body: some View { let resolved = ResolvedRowDisplay( @@ -31,9 +40,9 @@ struct SidebarItemView: View { worktreeName: store.sidebarDisplayName, isMainWorktree: store.isMainWorktree, isPinned: store.isPinned, - displayMode: displayMode, hideSubtitle: hideSubtitle, - hideSubtitleOnMatch: hideSubtitleOnMatch + hideSubtitleOnMatch: hideSubtitleOnMatch, + highlightSubtitle: highlightSubtitle ) Label { @@ -70,8 +79,18 @@ struct SidebarItemView: View { } struct ResolvedRowDisplay: Equatable { + enum Subtitle: Equatable { + case none + /// Standard per-repo subtitle. Rendered in the row's accent color. + case plain(String) + /// Highlight-section subtitle: `repo · trail`. `repo` paints with + /// `repoColor`, `trail` with the row's accent. `trail == nil` collapses + /// to just the repo name. + case highlight(repo: String, repoColor: RepositoryColor?, trail: String?) + } + let name: String - let subtitle: String? + let subtitle: Subtitle let accent: WorktreeAccent init( @@ -80,33 +99,52 @@ struct ResolvedRowDisplay: Equatable { worktreeName: String?, isMainWorktree: Bool, isPinned: Bool, - displayMode: WorktreeRowDisplayMode, hideSubtitle: Bool, - hideSubtitleOnMatch: Bool + hideSubtitleOnMatch: Bool, + highlightSubtitle: SidebarHighlightRepoTag? = nil ) { self.accent = if isMainWorktree { .main } else if isPinned { .pinned } else { .default } if kind == .folder { self.name = branchName - self.subtitle = nil + // Folder rows ARE the repo, so a repo prefix would just repeat the title. + self.subtitle = .none return } let resolvedWorktreeName = worktreeName ?? "Default" let effectiveWorktreeName = resolvedWorktreeName.isEmpty ? branchName : resolvedWorktreeName - switch displayMode { - case .branchFirst: self.name = branchName - case .worktreeFirst: self.name = effectiveWorktreeName - } + self.name = branchName let branchLastComponent = branchName.split(separator: "/").last.map(String.init) ?? branchName let isMatch = effectiveWorktreeName == branchLastComponent - let rawSubtitle = displayMode == .branchFirst ? effectiveWorktreeName : branchName + + if let highlightSubtitle { + // Hide-on-match drop mirrors the per-repo subtitle path so a row + // doesn't change its disambiguation policy across hoisting. + let trail: String? + if hideSubtitleOnMatch && isMatch { + trail = nil + } else if isMainWorktree { + trail = "Default" + } else if let worktreeName, !worktreeName.isEmpty { + trail = worktreeName + } else { + trail = nil + } + self.subtitle = .highlight( + repo: highlightSubtitle.repoName, + repoColor: highlightSubtitle.repoColor, + trail: trail + ) + return + } + if hideSubtitle || (hideSubtitleOnMatch && isMatch) { - self.subtitle = nil + self.subtitle = .none } else { - self.subtitle = rawSubtitle + self.subtitle = .plain(effectiveWorktreeName) } } } @@ -190,7 +228,7 @@ private func resolveCheckBadgeState(_ pullRequest: GithubPullRequest?) -> Sideba private struct TitleView: View, Equatable { let name: String - let subtitle: String? + let subtitle: ResolvedRowDisplay.Subtitle let accent: WorktreeAccent let isLifecycleBusy: Bool let isTaskRunning: Bool @@ -207,16 +245,45 @@ private struct TitleView: View, Equatable { var body: some View { let isBusy = isLifecycleBusy || isTaskRunning + let isEmphasized = backgroundProminence == .increased + let accentStyle = accent.shapeStyle(emphasized: isEmphasized) VStack(alignment: .leading, spacing: 0) { Text(name) .font(.body) .lineLimit(1) .shimmer(isActive: isBusy) - if let subtitle { - Text(subtitle) + switch subtitle { + case .none: + EmptyView() + case .plain(let text): + Text(text) .font(.footnote) - .foregroundStyle(accent.shapeStyle(emphasized: backgroundProminence == .increased)) + .foregroundStyle(accentStyle) .lineLimit(1) + case .highlight(let repo, let repoColor, let trail): + let repoStyle: AnyShapeStyle = + isEmphasized + ? AnyShapeStyle(.secondary) + : repoColor.map { AnyShapeStyle($0.color) } ?? AnyShapeStyle(.secondary) + // `.layoutPriority(1)` on the trail makes the repo prefix yield first + // under a narrow sidebar so the disambiguator survives truncation. + HStack(spacing: 0) { + Text(repo) + .foregroundStyle(repoStyle) + .lineLimit(1) + if let trail { + Text(" · ") + .foregroundStyle(.secondary) + .lineLimit(1) + Text(trail) + .foregroundStyle(accentStyle) + .lineLimit(1) + .layoutPriority(1) + } + } + .font(.footnote) + .accessibilityElement(children: .combine) + .accessibilityLabel(trail.map { "\(repo), \($0)" } ?? repo) } } } diff --git a/supacode/Features/Repositories/Views/SidebarItemsView.swift b/supacode/Features/Repositories/Views/SidebarItemsView.swift index 349456b2f..3f5bef96e 100644 --- a/supacode/Features/Repositories/Views/SidebarItemsView.swift +++ b/supacode/Features/Repositories/Views/SidebarItemsView.swift @@ -9,20 +9,20 @@ private nonisolated let notificationLogger = SupaLogger("Notifications") struct SidebarItemsView: View { let repository: Repository - let hotkeyIDs: [Worktree.ID] + /// Precomputed per-repo slot layout from `SidebarStructure`. The view does + /// no slot derivation: it walks `groups` in order and renders. + let groups: [SidebarItemGroup] + /// Already-resolved shortcut hint strings from the structure's `slotByID` + /// joined with `commandKeyObserver.isPressed` + shortcut overrides at the + /// `SidebarListView` level. `nil` here means "no hint to render". + let shortcutHintByID: [Worktree.ID: String] let selectedWorktreeIDs: Set @Bindable var store: StoreOf let terminalManager: WorktreeTerminalManager - @Environment(CommandKeyObserver.self) private var commandKeyObserver @Shared(.sidebarNestWorktreesByBranch) private var nestWorktreesByBranch: Bool var body: some View { - let groups = SidebarItemGroup.slots(in: store.state, repositoryID: repository.id) let isRepositoryRemoving = store.state.isRemovingRepository(repository) - let showShortcutHints = commandKeyObserver.isPressed - let shortcutIndexByID: [Worktree.ID: Int] = - showShortcutHints ? SidebarShortcutIndex.build(from: hotkeyIDs) : [:] - SidebarItemsDragOverlay( repository: repository, groups: groups, @@ -30,7 +30,7 @@ struct SidebarItemsView: View { store: store, terminalManager: terminalManager, isRepositoryRemoving: isRepositoryRemoving, - shortcutIndexByID: shortcutIndexByID, + shortcutHintByID: shortcutHintByID, nestWorktreesByBranch: nestWorktreesByBranch && repository.isGitRepository ) } @@ -45,7 +45,7 @@ private struct SidebarItemsDragOverlay: View { @Bindable var store: StoreOf let terminalManager: WorktreeTerminalManager let isRepositoryRemoving: Bool - let shortcutIndexByID: [Worktree.ID: Int] + let shortcutHintByID: [Worktree.ID: String] let nestWorktreesByBranch: Bool var body: some View { @@ -59,92 +59,13 @@ private struct SidebarItemsDragOverlay: View { isRepositoryRemoving: isRepositoryRemoving, hideSubtitle: group.hideSubtitle, moveBehavior: group.moveBehavior, - shortcutIndexByID: shortcutIndexByID, + shortcutHintByID: shortcutHintByID, nestWorktreesByBranch: nestWorktreesByBranch && group.supportsBranchNesting ) } } } -struct SidebarItemGroup: Identifiable { - enum MoveBehavior: Hashable { - case disabled - case pinned(Repository.ID) - case unpinned(Repository.ID) - } - - enum Slot: Hashable { - case main(isSole: Bool) - case pinnedTail - case pending - case unpinnedTail - } - - let slot: Slot - let repositoryID: Repository.ID - let rowIDs: [SidebarItemID] - - var id: Slot { slot } - - var hideSubtitle: Bool { - if case .main(let isSole) = slot { isSole } else { false } - } - - var moveBehavior: MoveBehavior { - switch slot { - case .main, .pending: .disabled - case .pinnedTail: .pinned(repositoryID) - case .unpinnedTail: .unpinned(repositoryID) - } - } - - /// Only the pinned and unpinned tails participate in branch nesting. - /// The main and pending slots are structural and shouldn't be folded into a tree. - var supportsBranchNesting: Bool { - switch slot { - case .pinnedTail, .unpinnedTail: true - case .main, .pending: false - } - } -} - -extension SidebarItemGroup { - /// Split one repo's bucketed item IDs into the four ordered slots the - /// sidebar renders (`main`, `pinnedTail`, `pending`, `unpinnedTail`). - /// Static rather than top-level per the AGENTS.md "no free functions" - /// rule. The reducer's `orderedSidebarItemIDs` mirrors this partition - /// so hotkeys / arrow-nav agree with the visible row order. - static func slots( - in state: RepositoriesFeature.State, - repositoryID: Repository.ID - ) -> [SidebarItemGroup] { - guard let bucket = state.sidebarGrouping.bucketsByRepository[repositoryID] else { return [] } - let pinnedRows = bucket[.pinned] - let unpinnedRows = bucket[.unpinned] - let pendingIDs = Set(state.pendingWorktrees.filter { $0.repositoryID == repositoryID }.map(\.id)) - - let mainID: SidebarItemID? = pinnedRows.first.flatMap { - state.sidebarItems[id: $0]?.isMainWorktree == true ? $0 : nil - } - let pinnedTail = pinnedRows.filter { $0 != mainID } - let pendingTail = unpinnedRows.filter { pendingIDs.contains($0) } - let unpinnedTail = unpinnedRows.filter { !pendingIDs.contains($0) } - let isSoleDefaultWorktree = - mainID != nil && pinnedTail.isEmpty && pendingTail.isEmpty && unpinnedTail.isEmpty - - return [ - SidebarItemGroup( - slot: .main(isSole: isSoleDefaultWorktree), - repositoryID: repositoryID, - rowIDs: mainID.map { [$0] } ?? [] - ), - SidebarItemGroup(slot: .pinnedTail, repositoryID: repositoryID, rowIDs: pinnedTail), - SidebarItemGroup(slot: .pending, repositoryID: repositoryID, rowIDs: pendingTail), - SidebarItemGroup(slot: .unpinnedTail, repositoryID: repositoryID, rowIDs: unpinnedTail), - ] - } -} - private struct SidebarItemGroupView: View { let repository: Repository let rowIDs: [SidebarItemID] @@ -154,7 +75,7 @@ private struct SidebarItemGroupView: View { let isRepositoryRemoving: Bool let hideSubtitle: Bool let moveBehavior: SidebarItemGroup.MoveBehavior - let shortcutIndexByID: [Worktree.ID: Int] + let shortcutHintByID: [Worktree.ID: String] let nestWorktreesByBranch: Bool var body: some View { @@ -177,7 +98,7 @@ private struct SidebarItemGroupView: View { // cross-group drags would snap back when the tree re-derives from branch // names, and the alphabetical sort would clobber any in-bucket reorder. let shortcutHintBuilder: (SidebarItemID) -> String? = { rowID in - shortcutHint(for: shortcutIndexByID[rowID]) + shortcutHintByID[rowID] } switch moveBehavior { case .disabled: @@ -249,23 +170,34 @@ private struct SidebarItemGroupView: View { return result } - @Shared(.settingsFile) private var settingsFile - - private func shortcutHint(for index: Int?) -> String? { - guard let index else { return nil } - return AppShortcuts.worktreeSelectionShortcutDisplay( - atSlot: index, - overrides: settingsFile.global.shortcutOverrides - ) - } - private func moveRows(_ offsets: IndexSet, _ destination: Int) { + // `rowIDs` here is the post-hoisting visible list; the full bucket lives + // on `sidebar.sections`. Translate against the full order so hoisted + // siblings keep their relative positions across the move. + let target: (repositoryID: Repository.ID, bucket: SidebarBucket) + switch moveBehavior { + case .disabled: return + case .pinned(let id): target = (id, .pinned) + case .unpinned(let id): target = (id, .unpinned) + } + guard + let fullKeys = store.state.sidebar.sections[target.repositoryID]? + .buckets[target.bucket]?.items.keys + else { return } + guard + let translated = SidebarItemGroup.translateFilteredMove( + offsets: offsets, + destination: destination, + visibleIDs: rowIDs, + fullIDs: Array(fullKeys) + ) + else { return } switch moveBehavior { - case .disabled: break - case .pinned(let repositoryID): - store.send(.pinnedWorktreesMoved(repositoryID: repositoryID, offsets, destination)) - case .unpinned(let repositoryID): - store.send(.unpinnedWorktreesMoved(repositoryID: repositoryID, offsets, destination)) + case .disabled: return + case .pinned(let id): + store.send(.pinnedWorktreesMoved(repositoryID: id, translated.offsets, translated.destination)) + case .unpinned(let id): + store.send(.unpinnedWorktreesMoved(repositoryID: id, translated.offsets, translated.destination)) } } } @@ -472,7 +404,7 @@ enum SidebarRowMoveMode { case conditional } -private struct SidebarItemRow: View { +struct SidebarItemRow: View { let rowID: SidebarItemID @Bindable var store: StoreOf let terminalManager: WorktreeTerminalManager @@ -483,6 +415,9 @@ private struct SidebarItemRow: View { let shortcutHint: String? var displayNameOverride: String? var nestDepth: Int = 0 + /// Non-nil while the row is rendered inside the global Pinned / Active + /// sections; injected as a `repo · worktree` subtitle disambiguator. + var highlightSubtitle: SidebarHighlightRepoTag? var body: some View { if let itemStore = store.scope(state: \.sidebarItems[id: rowID], action: \.sidebarItems[id: rowID]) { @@ -496,7 +431,8 @@ private struct SidebarItemRow: View { moveMode: moveMode, shortcutHint: shortcutHint, displayNameOverride: displayNameOverride, - nestDepth: nestDepth + nestDepth: nestDepth, + highlightSubtitle: highlightSubtitle ) } } @@ -513,9 +449,41 @@ private struct SidebarItemContainer: View { let shortcutHint: String? var displayNameOverride: String? var nestDepth: Int = 0 - @Shared(.appStorage("worktreeRowDisplayMode")) private var displayMode: WorktreeRowDisplayMode = .branchFirst + var highlightSubtitle: SidebarHighlightRepoTag? @Shared(.appStorage("worktreeRowHideSubtitleOnMatch")) private var hideSubtitleOnMatch = true + var body: some View { + SidebarItemBody( + store: store, + parentStore: parentStore, + terminalManager: terminalManager, + selectedWorktreeIDs: selectedWorktreeIDs, + isRepositoryRemoving: isRepositoryRemoving, + hideSubtitle: hideSubtitle, + moveMode: moveMode, + shortcutHint: shortcutHint, + displayNameOverride: displayNameOverride, + nestDepth: nestDepth, + highlightSubtitle: highlightSubtitle, + hideSubtitleOnMatch: hideSubtitleOnMatch + ) + } +} + +private struct SidebarItemBody: View { + let store: StoreOf + @Bindable var parentStore: StoreOf + let terminalManager: WorktreeTerminalManager + let selectedWorktreeIDs: Set + let isRepositoryRemoving: Bool + let hideSubtitle: Bool + let moveMode: SidebarRowMoveMode + let shortcutHint: String? + let displayNameOverride: String? + let nestDepth: Int + let highlightSubtitle: SidebarHighlightRepoTag? + let hideSubtitleOnMatch: Bool + var body: some View { let rowID = store.state.id let lifecycle = store.lifecycle @@ -528,13 +496,13 @@ private struct SidebarItemContainer: View { } SidebarItemView( store: store, - displayMode: displayMode, hideSubtitle: hideSubtitle, hideSubtitleOnMatch: hideSubtitleOnMatch, showsPullRequestInfo: !isDragging, shortcutHint: shortcutHint, displayNameOverride: displayNameOverride, - nestDepth: nestDepth + nestDepth: nestDepth, + highlightSubtitle: highlightSubtitle ) .environment(\.focusNotificationAction) { notification in guard let terminalState = terminalManager.stateIfExists(for: rowID) else { @@ -582,57 +550,32 @@ private struct SidebarItemContainer: View { } } -/// Folder repos render one row that must be a direct child of the outer `.onMove` to receive repo-level drags. +/// Folder repos render one row that must be a direct child of the outer +/// `.onMove` to receive repo-level drags. The structure pre-resolves the +/// synthetic worktree id and the shortcut hint; the view does no lookup. struct SidebarFolderRow: View { let repository: Repository - let hotkeyIDs: [Worktree.ID] + let rowID: Worktree.ID + let shortcutHint: String? let selectedWorktreeIDs: Set @Bindable var store: StoreOf let terminalManager: WorktreeTerminalManager - @Environment(CommandKeyObserver.self) private var commandKeyObserver - @Shared(.settingsFile) private var settingsFile var body: some View { - let state = store.state - let isRepositoryRemoving = state.isRemovingRepository(repository) - if let rowID = state.sidebarGrouping.bucketsByRepository[repository.id]?[.pinned].first { - SidebarItemRow( - rowID: rowID, - store: store, - terminalManager: terminalManager, - selectedWorktreeIDs: selectedWorktreeIDs, - isRepositoryRemoving: isRepositoryRemoving, - hideSubtitle: true, - moveMode: .alwaysEnabled, - shortcutHint: shortcutHint(for: rowID) - ) - } - } - - // Folder rows show a single hint, so a linear scan beats allocating a dict per render. - private func shortcutHint(for rowID: Worktree.ID) -> String? { - guard commandKeyObserver.isPressed, - let index = hotkeyIDs.firstIndex(of: rowID) - else { return nil } - return AppShortcuts.worktreeSelectionShortcutDisplay( - atSlot: index, - overrides: settingsFile.global.shortcutOverrides + let isRepositoryRemoving = store.state.isRemovingRepository(repository) + SidebarItemRow( + rowID: rowID, + store: store, + terminalManager: terminalManager, + selectedWorktreeIDs: selectedWorktreeIDs, + isRepositoryRemoving: isRepositoryRemoving, + hideSubtitle: true, + moveMode: .alwaysEnabled, + shortcutHint: shortcutHint ) } } -private enum SidebarShortcutIndex { - /// Defensive against a forged bucket roster: a duplicate `Worktree.ID` would trap - /// `Dictionary(uniqueKeysWithValues:)` inside the SwiftUI render loop. Keep the first - /// slot and fire loudly in DEBUG so a real invariant break surfaces in dev, not prod. - static func build(from hotkeyIDs: [Worktree.ID]) -> [Worktree.ID: Int] { - Dictionary(hotkeyIDs.enumerated().map { ($0.element, $0.offset) }) { first, _ in - assertionFailure("Duplicate Worktree.ID in sidebar hotkey order.") - return first - } - } -} - private struct SidebarItemContextMenu: View { let worktree: Worktree let rowID: SidebarItemID @@ -696,18 +639,24 @@ private struct SidebarItemContextMenu: View { Divider() } - let pinnableRows = contextRows.filter { !$0.isMainWorktree } + // Folder synthetic rows pass `isMainWorktree` by geometry but are + // pinnable; git "main" rows still aren't. + let pinnableRows = contextRows.filter { !$0.isMainWorktree || $0.isFolder } if !pinnableRows.isEmpty { let allPinned = pinnableRows.allSatisfy(\.isPinned) + let allFolders = pinnableRows.allSatisfy(\.isFolder) + // Folder-only selection reads "Pin Folder" / "Pin Folders"; mixed or + // git-only fall back to "Worktree" so the label stays accurate. + let noun = allFolders ? "Folder" : "Worktree" if allPinned { - let label = isBulkSelection ? "Unpin Worktrees" : "Unpin Worktree" + let label = isBulkSelection ? "Unpin \(noun)s" : "Unpin \(noun)" Button(label, systemImage: "pin.slash") { for pinnableRow in pinnableRows { togglePin(for: pinnableRow.id, isPinned: true) } } } else { - let label = isBulkSelection ? "Pin Worktrees" : "Pin Worktree" + let label = isBulkSelection ? "Pin \(noun)s" : "Pin \(noun)" Button(label, systemImage: "pin") { for pinnableRow in pinnableRows where !pinnableRow.isPinned { togglePin(for: pinnableRow.id, isPinned: false) diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index f5b1413e3..cb52b0a9f 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -9,12 +9,18 @@ struct SidebarListView: View { @Bindable var store: StoreOf let terminalManager: WorktreeTerminalManager @FocusState private var isSidebarFocused: Bool + @Environment(CommandKeyObserver.self) private var commandKeyObserver + @Shared(.settingsFile) private var settingsFile + /// Read here purely so SwiftUI re-runs the body (and fires the `.onChange` + /// below) when the menu writes a new value. The structure compute itself + /// reads the toggles via local `@Shared` inside the reducer. + @Shared(.sidebarGroupPinnedRows) private var groupPinnedRows: Bool + @Shared(.sidebarGroupActiveRows) private var groupActiveRows: Bool + @Shared(.sidebarNestWorktreesByBranch) private var nestWorktreesByBranch: Bool var body: some View { let state = store.state - let expandedRepoIDs = state.expandedRepositoryIDs - let hotkeyIDs = state.orderedSidebarItemIDs(includingRepositoryIDs: expandedRepoIDs) - let orderedRoots = state.orderedRepositoryRoots() + let structure = state.sidebarStructure let selectedWorktreeIDs = state.sidebarSelectedWorktreeIDs let currentSelections = state.sidebarSelections let selection = Binding>( @@ -24,49 +30,54 @@ struct SidebarListView: View { store.send(.selectionChanged(newValue)) } ) - let repositoriesByID = Dictionary(uniqueKeysWithValues: store.repositories.map { ($0.id, $0) }) let pendingSidebarReveal = state.pendingSidebarReveal + // The only legal view-side computation: a trivial join from the + // reducer-derived `slotByID` against the Cmd state + shortcut overrides. + // Gated on `isPressed` so the dict is empty when no hints are visible. + let shortcutHintByID: [Worktree.ID: String] + if commandKeyObserver.isPressed { + let overrides = settingsFile.global.shortcutOverrides + shortcutHintByID = structure.slotByID.compactMapValues { index in + AppShortcuts.worktreeSelectionShortcutDisplay(atSlot: index, overrides: overrides) + } + } else { + shortcutHintByID = [:] + } + return ScrollViewReader { scrollProxy in List(selection: selection) { - if !state.isInitialLoadComplete, store.repositories.isEmpty { - SidebarPlaceholderView() - } else if orderedRoots.isEmpty { - ForEach(store.repositories) { repository in - SidebarRootView( - repository: repository, - hotkeyIDs: hotkeyIDs, - selectedWorktreeIDs: selectedWorktreeIDs, - store: store, - terminalManager: terminalManager - ) - } - } else { - ForEach(sidebarRootRows(from: orderedRoots), id: \.repositoryID) { row in - if let failureMessage = state.loadFailuresByID[row.repositoryID] { - SidebarFailedRepositoryRow( - rootURL: row.rootURL, - failureMessage: failureMessage, - store: store - ) - } else if let repository = repositoriesByID[row.repositoryID] { - SidebarRootView( - repository: repository, - hotkeyIDs: hotkeyIDs, - selectedWorktreeIDs: selectedWorktreeIDs, - store: store, - terminalManager: terminalManager - ) - } - } - .onMove { offsets, destination in - store.send(.repositoriesMoved(offsets, destination)) - } + ForEach(structure.sections) { section in + SidebarSectionDispatcher( + section: section, + structure: structure, + shortcutHintByID: shortcutHintByID, + selectedWorktreeIDs: selectedWorktreeIDs, + store: store, + terminalManager: terminalManager + ) + } + .onMove { offsets, destination in + handleRepositoryMove( + offsets: offsets, + destination: destination, + structure: structure + ) } } .listStyle(.sidebar) .focused($isSidebarFocused) .frame(minWidth: 220) + .focusedSceneValue(\.visibleHotkeyWorktreeRows, structure.hotkeySlots) + .onChange(of: groupPinnedRows, initial: false) { _, _ in + store.send(.sidebarGroupingTogglesChanged) + } + .onChange(of: groupActiveRows, initial: false) { _, _ in + store.send(.sidebarGroupingTogglesChanged) + } + .onChange(of: nestWorktreesByBranch, initial: false) { _, _ in + store.send(.sidebarNestByBranchChanged) + } .dropDestination(for: URL.self) { urls, _ in let fileURLs = urls.filter(\.isFileURL) guard !fileURLs.isEmpty else { return false } @@ -108,15 +119,54 @@ struct SidebarListView: View { } } - private func sidebarRootRows( - from orderedRoots: [URL] - ) -> [(rootURL: URL, repositoryID: Repository.ID)] { - orderedRoots.map { rootURL in - ( - rootURL: rootURL, - repositoryID: rootURL.standardizedFileURL.path(percentEncoded: false) - ) + /// SwiftUI's `.onMove` reports offsets in the flat ForEach data array. The + /// structure exposes `reorderableRepositoryIDs` so we can translate a flat + /// move into the repository index space the `.repositoriesMoved` reducer + /// expects. Non-repo sections carry `.moveDisabled(true)` so they can't be + /// sources of a drag; the destination clamps below. + private func handleRepositoryMove( + offsets: IndexSet, + destination: Int, + structure: SidebarStructure + ) { + let repoIDs = structure.reorderableRepositoryIDs + guard !repoIDs.isEmpty else { return } + let sourceFlat = offsets.sorted() + let sectionsCount = structure.sections.count + // Map flat section indices to repo indices via SectionID matching. Skip + // any flat offset that doesn't correspond to a reorderable repo section. + var repoOffsets = IndexSet() + for index in sourceFlat where index < sectionsCount { + let section = structure.sections[index] + switch section { + case .repository(let repositoryID, _), + .folder(let repositoryID, _), + .failedRepository(let repositoryID, _, _): + if let repoIndex = repoIDs.firstIndex(of: repositoryID) { + repoOffsets.insert(repoIndex) + } + case .highlight, .placeholder: + continue + } } + guard !repoOffsets.isEmpty else { return } + let clampedDestination = min(max(destination, 0), sectionsCount) + let repoDestination: Int + if clampedDestination >= sectionsCount { + repoDestination = repoIDs.count + } else { + let section = structure.sections[clampedDestination] + switch section { + case .repository(let repositoryID, _), + .folder(let repositoryID, _), + .failedRepository(let repositoryID, _, _): + repoDestination = repoIDs.firstIndex(of: repositoryID) ?? repoIDs.count + case .highlight, .placeholder: + // Dropping above the highlight prefix collapses to "before the first repo". + repoDestination = 0 + } + } + store.send(.repositoriesMoved(repoOffsets, repoDestination)) } @MainActor @@ -136,46 +186,75 @@ struct SidebarListView: View { } } -private struct SidebarRootView: View { - let repository: Repository - let hotkeyIDs: [Worktree.ID] +/// Single switch that turns one `SidebarStructure.Section` into the right +/// SwiftUI view. The view has no other dispatch: the structure already +/// answered "what kind of section, what rows, in what order". +private struct SidebarSectionDispatcher: View { + let section: SidebarStructure.Section + let structure: SidebarStructure + let shortcutHintByID: [Worktree.ID: String] let selectedWorktreeIDs: Set @Bindable var store: StoreOf let terminalManager: WorktreeTerminalManager var body: some View { - if repository.isGitRepository { - SidebarSectionView( - repository: repository, - hotkeyIDs: hotkeyIDs, - selectedWorktreeIDs: selectedWorktreeIDs, + switch section { + case .placeholder: + SidebarPlaceholderView() + .moveDisabled(true) + case .highlight(let kind, let rowIDs): + SidebarHighlightSection( + kind: kind, + rowIDs: rowIDs, store: store, - terminalManager: terminalManager + terminalManager: terminalManager, + selectedWorktreeIDs: selectedWorktreeIDs, + repositoryHighlightByID: structure.repositoryHighlightByID, + shortcutHintByID: shortcutHintByID ) - } else { - // Folder repos render a single flat row so the outer - // `ForEach(sidebarRootRows).onMove` can reorder them alongside - // git sections. `SidebarItemsView`'s nested - // ForEach-of-groups-of-rows would hide the folder from the - // outer `.onMove`, breaking sidebar-wide drag. - Section { - SidebarFolderRow( + .moveDisabled(true) + case .failedRepository(_, let rootURL, let failureMessage): + SidebarFailedRepositoryRow( + rootURL: rootURL, + failureMessage: failureMessage, + store: store + ) + case .folder(let repositoryID, let rowID): + if let repository = store.state.repositories[id: repositoryID] { + // Empty header keeps `.listStyle(.sidebar)` from merging two + // consecutive folder repos visually. + Section { + SidebarFolderRow( + repository: repository, + rowID: rowID, + shortcutHint: shortcutHintByID[rowID], + selectedWorktreeIDs: selectedWorktreeIDs, + store: store, + terminalManager: terminalManager + ) + } header: { + EmptyView() + } + } + case .repository(let repositoryID, let groups): + if let repository = store.state.repositories[id: repositoryID] { + SidebarGitRepositorySection( repository: repository, - hotkeyIDs: hotkeyIDs, + groups: groups, + shortcutHintByID: shortcutHintByID, selectedWorktreeIDs: selectedWorktreeIDs, store: store, terminalManager: terminalManager ) - } header: { - EmptyView() } } } } -private struct SidebarSectionView: View { +private struct SidebarGitRepositorySection: View { let repository: Repository - let hotkeyIDs: [Worktree.ID] + let groups: [SidebarItemGroup] + let shortcutHintByID: [Worktree.ID: String] let selectedWorktreeIDs: Set @Bindable var store: StoreOf let terminalManager: WorktreeTerminalManager @@ -185,7 +264,8 @@ private struct SidebarSectionView: View { Section(isExpanded: repositoryExpansionBinding) { SidebarItemsView( repository: repository, - hotkeyIDs: hotkeyIDs, + groups: groups, + shortcutHintByID: shortcutHintByID, selectedWorktreeIDs: selectedWorktreeIDs, store: store, terminalManager: terminalManager diff --git a/supacode/Features/Repositories/Views/SidebarView.swift b/supacode/Features/Repositories/Views/SidebarView.swift index caa25bba5..74471d3e8 100644 --- a/supacode/Features/Repositories/Views/SidebarView.swift +++ b/supacode/Features/Repositories/Views/SidebarView.swift @@ -10,7 +10,6 @@ struct SidebarView: View { var body: some View { let state = store.state - let visibleHotkeyRows = state.hotkeyWorktreeSlots(includingRepositoryIDs: state.expandedRepositoryIDs) let effectiveSelectedRows = state.effectiveSidebarSelectedRows let confirmWorktreeAction = makeConfirmWorktreeAction(state: state) let archiveWorktreeAction = makeArchiveWorktreeAction(rows: effectiveSelectedRows) @@ -41,7 +40,6 @@ struct SidebarView: View { .focusedSceneValue(\.confirmWorktreeAction, confirmWorktreeAction) .focusedValue(\.archiveWorktreeAction, archiveWorktreeAction) .focusedValue(\.deleteWorktreeAction, deleteWorktreeAction) - .focusedSceneValue(\.visibleHotkeyWorktreeRows, visibleHotkeyRows) } private func makeConfirmWorktreeAction( diff --git a/supacode/Features/Repositories/Views/WorktreeRowDisplayMode.swift b/supacode/Features/Repositories/Views/WorktreeRowDisplayMode.swift deleted file mode 100644 index e2ab743cf..000000000 --- a/supacode/Features/Repositories/Views/WorktreeRowDisplayMode.swift +++ /dev/null @@ -1,13 +0,0 @@ -enum WorktreeRowDisplayMode: String, CaseIterable, Identifiable, Codable, Sendable { - case branchFirst - case worktreeFirst - - var id: String { rawValue } - - var label: String { - switch self { - case .branchFirst: "Branch Name First" - case .worktreeFirst: "Worktree Name First" - } - } -} diff --git a/supacodeTests/AppFeatureRunScriptTests.swift b/supacodeTests/AppFeatureRunScriptTests.swift index 59ae8c83f..6b3b3f8cf 100644 --- a/supacodeTests/AppFeatureRunScriptTests.swift +++ b/supacodeTests/AppFeatureRunScriptTests.swift @@ -56,6 +56,7 @@ struct AppFeatureRunScriptTests { await store.receive(\.repositories.sidebarItems) { $0.repositories.sidebarItems[id: worktree.id]?.runningScripts[id: definition.id] = .init(id: definition.id, tint: definition.resolvedTintColor) + $0.repositories.reconcileSidebarForTesting() } await store.finish() @@ -93,6 +94,7 @@ struct AppFeatureRunScriptTests { await store.receive(\.repositories.sidebarItems) { $0.repositories.sidebarItems[id: worktree.id]?.runningScripts[id: definition.id] = .init(id: definition.id, tint: definition.resolvedTintColor) + $0.repositories.reconcileSidebarForTesting() } await store.finish() } @@ -130,6 +132,10 @@ struct AppFeatureRunScriptTests { var repositoriesState = repositories repositoriesState.sidebarItems[id: worktree.id]?.runningScripts[id: definition.id] = .init(id: definition.id, tint: definition.resolvedTintColor) + // Re-reconcile after the ad-hoc runningScripts seed so the sidebar + // structure cache reflects the seeded state. Otherwise the post-reduce + // hook would surface a phantom structure mutation on the first dispatch. + repositoriesState.reconcileSidebarForTesting() let store = TestStore( initialState: AppFeature.State( @@ -153,6 +159,7 @@ struct AppFeatureRunScriptTests { ) await store.receive(\.repositories.sidebarItems) { $0.repositories.sidebarItems[id: worktree.id]?.runningScripts.remove(id: definition.id) + $0.repositories.reconcileSidebarForTesting() } } @@ -243,6 +250,7 @@ struct AppFeatureRunScriptTests { var repositoriesState = repositories repositoriesState.sidebarItems[id: worktree.id]?.runningScripts[id: definition.id] = .init(id: definition.id, tint: definition.resolvedTintColor) + repositoriesState.reconcileSidebarForTesting() let store = TestStore( initialState: AppFeature.State( @@ -268,6 +276,7 @@ struct AppFeatureRunScriptTests { ) await store.receive(\.repositories.sidebarItems) { $0.repositories.sidebarItems[id: worktree.id]?.runningScripts.remove(id: definition.id) + $0.repositories.reconcileSidebarForTesting() } } @@ -309,6 +318,7 @@ struct AppFeatureRunScriptTests { await store.receive(\.repositories.sidebarItems) { $0.repositories.sidebarItems[id: worktree.id]?.runningScripts[id: globalScript.id] = .init(id: globalScript.id, tint: globalScript.resolvedTintColor) + $0.repositories.reconcileSidebarForTesting() } await store.finish() diff --git a/supacodeTests/RepositoriesFeatureCustomizationTests.swift b/supacodeTests/RepositoriesFeatureCustomizationTests.swift index 155f0e6e1..45976fe0f 100644 --- a/supacodeTests/RepositoriesFeatureCustomizationTests.swift +++ b/supacodeTests/RepositoriesFeatureCustomizationTests.swift @@ -95,6 +95,7 @@ struct RepositoriesFeatureCustomizationTests { sidebar.sections[self.repoID, default: .init()].title = "Renamed" sidebar.sections[self.repoID, default: .init()].color = .red } + $0.recomputeSidebarStructureIfChanged() } } diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 2c5a97651..0d5b92bbc 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -288,28 +288,35 @@ struct RepositoriesFeatureTests { @Test func sidebarRepositoryExpansionChangedUpdatesCollapsedRepositoryIDs() async { let worktree = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) - let store = TestStore(initialState: makeState(repositories: [repository])) { + var initialState = makeState(repositories: [repository]) + initialState.reconcileSidebarForTesting() + let store = TestStore(initialState: initialState) { RepositoriesFeature() } await store.send(.repositoryExpansionChanged(repository.id, isExpanded: false)) { $0.$sidebar.withLock { $0.sections[repository.id, default: .init()].collapsed = true } + $0.recomputeSidebarStructureIfChanged() } await store.send(.repositoryExpansionChanged(repository.id, isExpanded: true)) { $0.$sidebar.withLock { $0.sections[repository.id, default: .init()].collapsed = false } + $0.recomputeSidebarStructureIfChanged() } } @Test func repositoryExpansionChangedIsIdempotent() async { let worktree = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) - let store = TestStore(initialState: makeState(repositories: [repository])) { + var initialState = makeState(repositories: [repository]) + initialState.reconcileSidebarForTesting() + let store = TestStore(initialState: initialState) { RepositoriesFeature() } await store.send(.repositoryExpansionChanged(repository.id, isExpanded: false)) { $0.$sidebar.withLock { $0.sections[repository.id, default: .init()].collapsed = true } + $0.recomputeSidebarStructureIfChanged() } // Collapsing again should be a no-op. @@ -440,7 +447,9 @@ struct RepositoriesFeatureTests { id: "/tmp/repo-b", worktrees: [makeWorktree(id: "/tmp/repo-b/wt1", name: "wt1", repoRoot: "/tmp/repo-b")], ) - let store = TestStore(initialState: makeState(repositories: [repoA, repoB])) { + var initialState = makeState(repositories: [repoA, repoB]) + initialState.reconcileSidebarForTesting() + let store = TestStore(initialState: initialState) { RepositoriesFeature() } @@ -450,9 +459,11 @@ struct RepositoriesFeatureTests { // bit flips on the targeted section. await store.send(.repositoryExpansionChanged(repoB.id, isExpanded: false)) { $0.$sidebar.withLock { $0.sections[repoB.id, default: .init()].collapsed = true } + $0.recomputeSidebarStructureIfChanged() } await store.send(.repositoryExpansionChanged(repoA.id, isExpanded: false)) { $0.$sidebar.withLock { $0.sections[repoA.id, default: .init()].collapsed = true } + $0.recomputeSidebarStructureIfChanged() } } @@ -655,7 +666,9 @@ struct RepositoriesFeatureTests { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) - let store = TestStore(initialState: makeState(repositories: [repository])) { + var initialState = makeState(repositories: [repository]) + initialState.reconcileSidebarForTesting() + let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } @@ -840,7 +853,9 @@ struct RepositoriesFeatureTests { id: repoRootB, worktrees: [makeWorktree(id: repoRootB, name: "main", repoRoot: repoRootB)] ) - let store = TestStore(initialState: makeState(repositories: [repoA, repoB])) { + var initialState = makeState(repositories: [repoA, repoB]) + initialState.reconcileSidebarForTesting() + let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.gitClient.automaticWorktreeBaseRef = { root in @@ -1061,6 +1076,7 @@ struct RepositoriesFeatureTests { ) ) { $0.alert = expectedAlert + $0.recomputeSidebarStructureIfChanged() } await store.finish() #expect(removed.value == false) @@ -1863,12 +1879,186 @@ struct RepositoriesFeatureTests { } } + @Test func folderPinUnpinFlowsThroughBucketMachinery() async { + // Folders use the same `pinWorktree` / `unpinWorktree` actions as git + // worktrees. The two invariants this test locks: + // 1. A pin reaches the `.pinned` bucket even though the folder's + // synthetic worktree wasn't pre-seeded into any bucket. + // 2. A subsequent `.repositoriesLoaded` round-trip does not scrub the + // pin. `reconcileSidebarState` previously dropped any bucket entry + // whose id matched a main worktree, and folder synthetics satisfy + // `isMainWorktree` by geometry, so without the fix the pin + // vanished on every reload. + let folderRoot = "/tmp/folder-pin-\(UUID().uuidString)" + let folderURL = URL(fileURLWithPath: folderRoot) + let folderWorktree = Worktree( + id: Repository.folderWorktreeID(for: folderURL), + name: Repository.name(for: folderURL), detail: "", + workingDirectory: folderURL, repositoryRootURL: folderURL + ) + let folderRepo = Repository( + id: folderRoot, rootURL: folderURL, name: Repository.name(for: folderURL), + worktrees: IdentifiedArray(uniqueElements: [folderWorktree]), + isGitRepository: false + ) + let store = TestStore(initialState: makeState(repositories: [folderRepo])) { + RepositoriesFeature() + } withDependencies: { + $0.analyticsClient.capture = { _, _ in } + } + store.exhaustivity = .off + + await store.send(.pinWorktree(folderWorktree.id)) + #expect(store.state.sidebar.sections[folderRepo.id]?.buckets[.pinned]?.items[folderWorktree.id] != nil) + #expect(store.state.sidebar.sections[folderRepo.id]?.buckets[.unpinned]?.items[folderWorktree.id] == nil) + #expect(store.state.sidebarItems[id: folderWorktree.id]?.isPinned == true) + + await store.send( + .repositoriesLoaded( + [folderRepo], + failures: [], + roots: [folderRepo.rootURL], + animated: false, + ) + ) + #expect(store.state.sidebar.sections[folderRepo.id]?.buckets[.pinned]?.items[folderWorktree.id] != nil) + #expect(store.state.sidebarItems[id: folderWorktree.id]?.isPinned == true) + + await store.send(.unpinWorktree(folderWorktree.id)) + #expect(store.state.sidebar.sections[folderRepo.id]?.buckets[.pinned]?.items[folderWorktree.id] == nil) + #expect(store.state.sidebar.sections[folderRepo.id]?.buckets[.unpinned]?.items[folderWorktree.id] != nil) + #expect(store.state.sidebarItems[id: folderWorktree.id]?.isPinned == false) + + await store.send( + .repositoriesLoaded( + [folderRepo], + failures: [], + roots: [folderRepo.rootURL], + animated: false, + ) + ) + #expect(store.state.sidebar.sections[folderRepo.id]?.buckets[.unpinned]?.items[folderWorktree.id] != nil) + #expect(store.state.sidebarItems[id: folderWorktree.id]?.isPinned == false) + } + + @Test func pinWorktreeCollapsesPreExistingDoubleBucketState() async { + // `removeAnywhere` + `insert` is supposed to enforce the + // "exactly one bucket" invariant against pre-states (hand-edit, + // migrator race) where the same id lives in `.pinned` and + // `.unpinned` simultaneously. Seed that pre-state explicitly and + // confirm `pinWorktree` collapses it to a single `.pinned` entry. + let worktree = makeWorktree(id: "/tmp/dbl-bucket/wt", name: "duck", repoRoot: "/tmp/dbl-bucket") + let repository = makeRepository(id: "/tmp/dbl-bucket", worktrees: [worktree]) + var initial = makeState(repositories: [repository]) + initial.$sidebar.withLock { sidebar in + sidebar.sections[repository.id] = .init( + buckets: [ + .pinned: .init(items: [worktree.id: .init()]), + .unpinned: .init(items: [worktree.id: .init()]), + ] + ) + } + let store = TestStore(initialState: initial) { + RepositoriesFeature() + } withDependencies: { + $0.analyticsClient.capture = { _, _ in } + } + store.exhaustivity = .off + + await store.send(.pinWorktree(worktree.id)) + let section = store.state.sidebar.sections[repository.id] + #expect(section?.buckets[.pinned]?.items[worktree.id] != nil) + #expect(section?.buckets[.unpinned]?.items[worktree.id] == nil) + } + + @Test func unpinWorktreeCollapsesPreExistingDoubleBucketState() async { + // Symmetric to `pinWorktreeCollapsesPreExistingDoubleBucketState`: an + // unpin against a row that lives in both `.pinned` and `.unpinned` + // must end with the row in `.unpinned` only. + let worktree = makeWorktree(id: "/tmp/dbl-bucket-u/wt", name: "duck", repoRoot: "/tmp/dbl-bucket-u") + let repository = makeRepository(id: "/tmp/dbl-bucket-u", worktrees: [worktree]) + var initial = makeState(repositories: [repository]) + initial.$sidebar.withLock { sidebar in + sidebar.sections[repository.id] = .init( + buckets: [ + .pinned: .init(items: [worktree.id: .init()]), + .unpinned: .init(items: [worktree.id: .init()]), + ] + ) + } + let store = TestStore(initialState: initial) { + RepositoriesFeature() + } withDependencies: { + $0.analyticsClient.capture = { _, _ in } + } + store.exhaustivity = .off + + await store.send(.unpinWorktree(worktree.id)) + let section = store.state.sidebar.sections[repository.id] + #expect(section?.buckets[.pinned]?.items[worktree.id] == nil) + #expect(section?.buckets[.unpinned]?.items[worktree.id] != nil) + } + + @Test func pinWorktreeIsNoOpOnArchivedRow() async { + // Bucket relocation uses `removeAnywhere` which strips `archivedAt` + // as a side effect. The archive guard refuses to relocate archived + // rows so the timestamp survives a stray deeplink / hotkey dispatch. + let worktree = makeWorktree(id: "/tmp/arch-pin/wt", name: "duck", repoRoot: "/tmp/arch-pin") + let repository = makeRepository(id: "/tmp/arch-pin", worktrees: [worktree]) + var initial = makeState(repositories: [repository]) + initial.$sidebar.withLock { sidebar in + sidebar.sections[repository.id] = .init( + buckets: [.archived: .init(items: [worktree.id: .init(archivedAt: .now)])] + ) + } + let store = TestStore(initialState: initial) { + RepositoriesFeature() + } withDependencies: { + $0.analyticsClient.capture = { _, _ in } + } + store.exhaustivity = .off + + await store.send(.pinWorktree(worktree.id)) + let archived = store.state.sidebar.sections[repository.id]?.buckets[.archived] + #expect(archived?.items[worktree.id] != nil) + #expect(archived?.items[worktree.id]?.archivedAt != nil) + #expect(store.state.sidebar.sections[repository.id]?.buckets[.pinned]?.items[worktree.id] == nil) + + await store.send(.unpinWorktree(worktree.id)) + let archivedAfterUnpin = store.state.sidebar.sections[repository.id]?.buckets[.archived] + #expect(archivedAfterUnpin?.items[worktree.id] != nil) + #expect(archivedAfterUnpin?.items[worktree.id]?.archivedAt != nil) + } + + @Test func orderedHighlightPinnedIDsFiltersArchived() { + // The Active candidate set already filters `.deletingScript` rows + // out; the pinned list does the same so a row in the middle of an + // archive delete can't double up across both highlight sections. + let pinnedWorktree = makeWorktree(id: "/tmp/filter/wt-pin", name: "pin", repoRoot: "/tmp/filter") + let archivedWorktree = makeWorktree(id: "/tmp/filter/wt-arch", name: "arch", repoRoot: "/tmp/filter") + let repository = makeRepository(id: "/tmp/filter", worktrees: [pinnedWorktree, archivedWorktree]) + var state = makeState(repositories: [repository]) + state.$sidebar.withLock { sidebar in + sidebar.sections[repository.id] = .init( + buckets: [ + .pinned: .init(items: [pinnedWorktree.id: .init()]), + .archived: .init(items: [archivedWorktree.id: .init(archivedAt: .now)]), + ] + ) + } + // Re-seed the pinned bucket with an archived id so the filter has + // something to drop (a hand-edit / migrator pre-state). + state.$sidebar.withLock { sidebar in + sidebar.sections[repository.id]?.buckets[.pinned]?.items[archivedWorktree.id] = .init() + } + let ids = state.orderedHighlightPinnedIDs() + #expect(ids == [pinnedWorktree.id]) + } + @Test func requestArchiveWorktreeForFolderShowsActionNotAvailable() async { - // S1: the deeplink layer rejects archive/pin/unpin on folders, - // but the hotkey / context-menu path used to silently no-op - // because the synthetic main-worktree satisfies `isMainWorktree` - // geometrically. Surface the same "Action not available" alert - // the deeplink shows. + // Archive still rejects on folders (no archived bucket for them); pin + // and unpin now flow through the standard bucket machinery so they + // produce no alert. See `folderPinUnpinFlowsThroughBucketMachinery`. let folderRoot = "/tmp/folder-archive-\(UUID().uuidString)" let folderURL = URL(fileURLWithPath: folderRoot) let folderWorktree = Worktree( @@ -1885,29 +2075,17 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } - // The helper produces a per-action title + body so users know - // which action they just tried. Keep each expected alert - // narrow to the one being exercised. - func expectedAlert(name: String) -> AlertState { - AlertState { - TextState("\(name) not available") - } actions: { - ButtonState(role: .cancel) { TextState("OK") } - } message: { - TextState("\(name) only applies to git repositories.") - } + let expectedAlert = AlertState { + TextState("Archive not available") + } actions: { + ButtonState(role: .cancel) { TextState("OK") } + } message: { + TextState("Archive only applies to git repositories.") } await store.send(.requestArchiveWorktree(folderWorktree.id, folderRepo.id)) { - $0.alert = expectedAlert(name: "Archive") - } - await store.send(.alert(.dismiss)) { $0.alert = nil } - await store.send(.pinWorktree(folderWorktree.id)) { - $0.alert = expectedAlert(name: "Pin") + $0.alert = expectedAlert } await store.send(.alert(.dismiss)) { $0.alert = nil } - await store.send(.unpinWorktree(folderWorktree.id)) { - $0.alert = expectedAlert(name: "Unpin") - } } @Test func requestArchiveWorktreesShowsBatchConfirmation() async { @@ -2021,6 +2199,7 @@ struct RepositoriesFeatureTests { state.reconcileSidebarForTesting() state.sidebarItems[id: worktree.id]?.runningScripts[id: definition.id] = .init(id: definition.id, tint: definition.resolvedTintColor) + state.recomputeSidebarStructureIfChanged() let store = TestStore(initialState: state) { RepositoriesFeature() @@ -2045,6 +2224,7 @@ struct RepositoriesFeatureTests { } await store.receive(\.sidebarItems) { $0.sidebarItems[id: worktree.id]?.runningScripts.remove(id: definition.id) + $0.reconcileSidebarForTesting() } } @@ -2057,6 +2237,7 @@ struct RepositoriesFeatureTests { state.reconcileSidebarForTesting() state.sidebarItems[id: worktree.id]?.runningScripts[id: definition.id] = .init(id: definition.id, tint: definition.resolvedTintColor) + state.recomputeSidebarStructureIfChanged() let store = TestStore(initialState: state) { RepositoriesFeature() @@ -2073,6 +2254,7 @@ struct RepositoriesFeatureTests { ) await store.receive(\.sidebarItems) { $0.sidebarItems[id: worktree.id]?.runningScripts.remove(id: definition.id) + $0.reconcileSidebarForTesting() } #expect(store.state.alert == nil) } @@ -2086,6 +2268,7 @@ struct RepositoriesFeatureTests { state.reconcileSidebarForTesting() state.sidebarItems[id: worktree.id]?.runningScripts[id: definition.id] = .init(id: definition.id, tint: definition.resolvedTintColor) + state.recomputeSidebarStructureIfChanged() let store = TestStore(initialState: state) { RepositoriesFeature() @@ -2102,6 +2285,7 @@ struct RepositoriesFeatureTests { ) await store.receive(\.sidebarItems) { $0.sidebarItems[id: worktree.id]?.runningScripts.remove(id: definition.id) + $0.reconcileSidebarForTesting() } #expect(store.state.alert == nil) } @@ -2153,6 +2337,7 @@ struct RepositoriesFeatureTests { .init(id: completing.id, tint: completing.resolvedTintColor) state.sidebarItems[id: worktree.id]?.runningScripts[id: surviving.id] = .init(id: surviving.id, tint: surviving.resolvedTintColor) + state.recomputeSidebarStructureIfChanged() let store = TestStore(initialState: state) { RepositoriesFeature() @@ -2169,6 +2354,7 @@ struct RepositoriesFeatureTests { ) await store.receive(\.sidebarItems) { $0.sidebarItems[id: worktree.id]?.runningScripts.remove(id: completing.id) + $0.reconcileSidebarForTesting() } #expect(store.state.alert == nil) } @@ -2377,7 +2563,9 @@ struct RepositoriesFeatureTests { repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) - let store = TestStore(initialState: makeState(repositories: [repository])) { + var initialState = makeState(repositories: [repository]) + initialState.reconcileSidebarForTesting() + let store = TestStore(initialState: initialState) { RepositoriesFeature() } @@ -2754,7 +2942,9 @@ struct RepositoriesFeatureTests { repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) - let store = TestStore(initialState: makeState(repositories: [repository])) { + var initialState = makeState(repositories: [repository]) + initialState.reconcileSidebarForTesting() + let store = TestStore(initialState: initialState) { RepositoriesFeature() } @@ -3261,6 +3451,7 @@ struct RepositoriesFeatureTests { sidebar.sections["/tmp/repo", default: .init()].buckets[.unpinned, default: .init()] .collapsedBranchPrefixes = ["feature"] } + state.reconcileSidebarForTesting() // feature/a is now hidden behind the collapsed `feature` group; visible // hotkey list is [main, alpha, zulu]. Anchor index of feature/a in the // unfiltered list sits between alpha and zulu. @@ -3269,6 +3460,39 @@ struct RepositoriesFeatureTests { #expect(state.worktreeID(byOffset: -1) == "/tmp/repo/alpha") } + @Test func worktreeIDByOffsetWalksHoistedRowsBeforePerRepoRows() { + // When a worktree is hoisted into the Pinned section, arrow nav must walk + // the hoisted row (visible at the top) before falling into the per-repo + // rows, matching what the user actually sees in the sidebar. + let repoRoot = "/tmp/repo" + let main = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let alpha = makeWorktree(id: "/tmp/repo/alpha", name: "alpha", repoRoot: repoRoot) + let bravo = makeWorktree(id: "/tmp/repo/bravo", name: "bravo", repoRoot: repoRoot) + var state = makeState(repositories: [makeRepository(id: repoRoot, worktrees: [main, alpha, bravo])]) + // Pin bravo so it hoists to the Pinned section at the top. + state.$sidebar.withLock { sidebar in + var section = sidebar.sections[repoRoot] ?? .init() + var pinnedBucket = section.buckets[.pinned] ?? .init() + pinnedBucket.items[bravo.id] = .init() + section.buckets[.pinned] = pinnedBucket + var unpinnedBucket = section.buckets[.unpinned] ?? .init() + unpinnedBucket.items.removeValue(forKey: bravo.id) + section.buckets[.unpinned] = unpinnedBucket + sidebar.sections[repoRoot] = section + } + state.reconcileSidebarForTesting() + + // Visible order: [bravo (Pinned hoist), main, alpha]. + state.setSingleWorktreeSelection(bravo.id) + #expect(state.worktreeID(byOffset: 1) == main.id) + state.setSingleWorktreeSelection(main.id) + #expect(state.worktreeID(byOffset: -1) == bravo.id) + state.setSingleWorktreeSelection(alpha.id) + // Wrap-around from last → first lands on the hoisted row, not the + // per-repo position bravo would have had in bucket order. + #expect(state.worktreeID(byOffset: 1) == bravo.id) + } + @Test func orderedRepositoryRootsAppendMissing() { let repoA = makeRepository(id: "/tmp/repo-a", worktrees: []) let repoB = makeRepository(id: "/tmp/repo-b", worktrees: []) @@ -3382,7 +3606,9 @@ struct RepositoriesFeatureTests { @Test func loadRepositoriesFailureKeepsPreviousState() async { let repository = makeRepository(id: "/tmp/repo", worktrees: []) - let store = TestStore(initialState: makeState(repositories: [repository])) { + var initialState = makeState(repositories: [repository]) + initialState.reconcileSidebarForTesting() + let store = TestStore(initialState: initialState) { RepositoriesFeature() } @@ -3397,6 +3623,7 @@ struct RepositoriesFeatureTests { $0.loadFailuresByID = [repository.id: "boom"] $0.repositories = [] $0.isInitialLoadComplete = true + $0.reconcileSidebarForTesting() } await store.receive(\.delegate.repositoriesChanged) @@ -3415,6 +3642,7 @@ struct RepositoriesFeatureTests { ] ) } + initialState.reconcileSidebarForTesting() let store = TestStore(initialState: initialState) { RepositoriesFeature() } @@ -3430,6 +3658,7 @@ struct RepositoriesFeatureTests { $0.loadFailuresByID = [repository.id: "boom"] $0.repositories = [] $0.isInitialLoadComplete = true + $0.reconcileSidebarForTesting() } await store.receive(\.delegate.repositoriesChanged) @@ -3454,6 +3683,7 @@ struct RepositoriesFeatureTests { item: .init(archivedAt: Date(timeIntervalSince1970: 1_000_000)) ) } + initialState.reconcileSidebarForTesting() let store = TestStore(initialState: initialState) { RepositoriesFeature() } @@ -3469,6 +3699,7 @@ struct RepositoriesFeatureTests { $0.loadFailuresByID = [repository.id: "boom"] $0.repositories = [] $0.isInitialLoadComplete = true + $0.reconcileSidebarForTesting() } await store.receive(\.delegate.repositoriesChanged) @@ -4203,6 +4434,13 @@ struct RepositoriesFeatureTests { let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { + // This test intentionally skips sidebar reconciliation; the PR refresh + // bookkeeping under test (`inFlightPullRequestRefreshRepositoryIDs`, + // `inFlightPullRequestBranchSnapshotsByRepositoryID`) doesn't read from + // `sidebarItems`. Opt out of the structure-cache recompute so the hook + // doesn't surface a placeholder → real mutation against the empty + // sidebar. + $0.sidebarStructureAutoRecompute = false $0.gitClient.remoteInfo = { _ in nil } $0.githubCLI.batchPullRequests = { _, _, _, _ in Issue.record("batchPullRequests should not run when remoteInfo is unavailable") @@ -4237,7 +4475,9 @@ struct RepositoriesFeatureTests { repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) - let store = TestStore(initialState: makeState(repositories: [repository])) { + var initialState = makeState(repositories: [repository]) + initialState.reconcileSidebarForTesting() + let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { $0.githubIntegration.isAvailable = { false } @@ -4292,6 +4532,7 @@ struct RepositoriesFeatureTests { let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) var initialState = makeState(repositories: [repository]) initialState.githubIntegrationAvailability = .unavailable + initialState.reconcileSidebarForTesting() let store = TestStore(initialState: initialState) { RepositoriesFeature() } @@ -4330,6 +4571,10 @@ struct RepositoriesFeatureTests { let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { + // Mirror the sibling worktreeInfoEvent tests: opt out of the structure + // cache recompute so the empty-sidebar starting state doesn't surface a + // placeholder → real mutation on the replayed refresh. + $0.sidebarStructureAutoRecompute = false $0.gitClient.remoteInfo = { _ in nil } $0.githubCLI.batchPullRequests = { _, _, _, _ in Issue.record("batchPullRequests should not run when remoteInfo is unavailable") @@ -4588,7 +4833,9 @@ struct RepositoriesFeatureTests { @Test func unarchiveWorktreeNoopsWhenNotArchived() async { let worktree = makeWorktree(id: "/tmp/wt", name: "owl") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) - let store = TestStore(initialState: makeState(repositories: [repository])) { + var initialState = makeState(repositories: [repository]) + initialState.reconcileSidebarForTesting() + let store = TestStore(initialState: initialState) { RepositoriesFeature() } @@ -5069,7 +5316,9 @@ struct RepositoriesFeatureTests { let repository = makeRepository(id: repoRoot, worktrees: [main, feature, bugfix]) var state = makeState(repositories: [repository]) state.selection = .worktree(main.id) - // Pin bugfix so the sidebar order is [main, bugfix, feature], not the raw worktree order. + // Pin bugfix so it hoists into the Pinned highlight section; visible order + // becomes [bugfix (hoist), main, feature]. Arrow nav from main lands on + // feature, not on bugfix's per-repo bucket position. state.$sidebar.withLock { sidebar in sidebar.sections[repoRoot] = .init(buckets: [.pinned: .init(items: [bugfix.id: .init()])]) } @@ -5080,8 +5329,8 @@ struct RepositoriesFeatureTests { await store.send(.selectNextWorktree) await store.receive(\.selectWorktree) { - $0.selection = .worktree(bugfix.id) - $0.sidebarSelectedWorktreeIDs = [bugfix.id] + $0.selection = .worktree(feature.id) + $0.sidebarSelectedWorktreeIDs = [feature.id] $0.worktreeHistoryBackStack = [main.id] } await store.receive(\.delegate.selectedWorktreeChanged) @@ -6217,6 +6466,7 @@ struct RepositoriesFeatureTests { $0.loadFailuresByID = [ repoRoot: "Directory not found at \(repoRoot). It may have been moved or deleted." ] + $0.reconcileSidebarForTesting() } await store.finish() } diff --git a/supacodeTests/RepositoriesSidebarTestHelpers.swift b/supacodeTests/RepositoriesSidebarTestHelpers.swift index 45b09b7de..8a1f3d65f 100644 --- a/supacodeTests/RepositoriesSidebarTestHelpers.swift +++ b/supacodeTests/RepositoriesSidebarTestHelpers.swift @@ -4,10 +4,15 @@ import Foundation @testable import supacode extension RepositoriesFeature.State { - /// Test mirror of `syncSidebar`. + /// Test mirror of the full sidebar pipeline: `syncSidebar` (matching + /// reducer-body handlers that explicitly resync) + the structure recompute + /// the post-reduce hook would run. `$0.reconcileSidebarForTesting()` in a + /// TestStore expectation covers both in one call so tests don't have to + /// remember to mirror each piece separately. @MainActor mutating func reconcileSidebarForTesting() { RepositoriesFeature.syncSidebar(&self) + recomputeSidebarStructureIfChanged() } /// Convenience init for tests that need a populated row/grouping store from a roster. diff --git a/supacodeTests/ResolvedRowDisplayTests.swift b/supacodeTests/ResolvedRowDisplayTests.swift new file mode 100644 index 000000000..86c76cb92 --- /dev/null +++ b/supacodeTests/ResolvedRowDisplayTests.swift @@ -0,0 +1,256 @@ +import SupacodeSettingsShared +import Testing + +@testable import supacode + +@MainActor +struct ResolvedRowDisplayTests { + // MARK: - Folder rows. + + @Test func folderKindRendersBranchAsTitleAndDropsSubtitle() { + let resolved = ResolvedRowDisplay( + kind: .folder, + branchName: "Documents", + worktreeName: "Documents", + isMainWorktree: true, + isPinned: false, + hideSubtitle: false, + hideSubtitleOnMatch: true + ) + #expect(resolved.name == "Documents") + #expect(resolved.subtitle == .none) + } + + @Test func folderKindIgnoresHighlightTagSinceFolderIsTheRepo() { + let resolved = ResolvedRowDisplay( + kind: .folder, + branchName: "Notes", + worktreeName: nil, + isMainWorktree: true, + isPinned: true, + hideSubtitle: false, + hideSubtitleOnMatch: false, + highlightSubtitle: SidebarHighlightRepoTag(repoName: "Notes", repoColor: nil) + ) + #expect(resolved.subtitle == .none) + } + + // MARK: - Per-repo subtitle path (no highlight tag). + + @Test func plainSubtitleShowsWorktreeNameWhenNoMatch() { + let resolved = ResolvedRowDisplay( + kind: .gitWorktree, + branchName: "feature/foo", + worktreeName: "scratch", + isMainWorktree: false, + isPinned: false, + hideSubtitle: false, + hideSubtitleOnMatch: true + ) + #expect(resolved.subtitle == .plain("scratch")) + } + + @Test func plainSubtitleHidesWhenWorktreeMatchesBranchLastComponentAndFlagOn() { + let resolved = ResolvedRowDisplay( + kind: .gitWorktree, + branchName: "feature/foo", + worktreeName: "foo", + isMainWorktree: false, + isPinned: false, + hideSubtitle: false, + hideSubtitleOnMatch: true + ) + #expect(resolved.subtitle == .none) + } + + @Test func plainSubtitleKeepsMatchingWorktreeNameWhenFlagOff() { + let resolved = ResolvedRowDisplay( + kind: .gitWorktree, + branchName: "feature/foo", + worktreeName: "foo", + isMainWorktree: false, + isPinned: false, + hideSubtitle: false, + hideSubtitleOnMatch: false + ) + #expect(resolved.subtitle == .plain("foo")) + } + + @Test func plainSubtitleSuppressedUnconditionallyByHideSubtitle() { + let resolved = ResolvedRowDisplay( + kind: .gitWorktree, + branchName: "feature/foo", + worktreeName: "scratch", + isMainWorktree: false, + isPinned: false, + hideSubtitle: true, + hideSubtitleOnMatch: false + ) + #expect(resolved.subtitle == .none) + } + + // MARK: - Highlight trail resolution (the four branches). + + @Test func highlightTrailIsDefaultForMainWorktree() { + let resolved = ResolvedRowDisplay( + kind: .gitWorktree, + branchName: "main", + worktreeName: nil, + isMainWorktree: true, + isPinned: false, + hideSubtitle: false, + hideSubtitleOnMatch: true, + highlightSubtitle: SidebarHighlightRepoTag(repoName: "supacode", repoColor: .blue) + ) + guard case .highlight(let repo, let color, let trail) = resolved.subtitle else { + Issue.record("Expected .highlight subtitle for main worktree") + return + } + #expect(repo == "supacode") + #expect(color == .blue) + #expect(trail == "Default") + } + + @Test func highlightTrailHidesOnMatchWhenFlagOn() { + let resolved = ResolvedRowDisplay( + kind: .gitWorktree, + branchName: "feature/foo", + worktreeName: "foo", + isMainWorktree: false, + isPinned: true, + hideSubtitle: false, + hideSubtitleOnMatch: true, + highlightSubtitle: SidebarHighlightRepoTag(repoName: "supacode", repoColor: nil) + ) + guard case .highlight(_, _, let trail) = resolved.subtitle else { + Issue.record("Expected .highlight subtitle") + return + } + #expect(trail == nil) + } + + @Test func highlightTrailKeepsMatchingWorktreeNameWhenFlagOff() { + let resolved = ResolvedRowDisplay( + kind: .gitWorktree, + branchName: "feature/foo", + worktreeName: "foo", + isMainWorktree: false, + isPinned: true, + hideSubtitle: false, + hideSubtitleOnMatch: false, + highlightSubtitle: SidebarHighlightRepoTag(repoName: "supacode", repoColor: nil) + ) + guard case .highlight(_, _, let trail) = resolved.subtitle else { + Issue.record("Expected .highlight subtitle") + return + } + #expect(trail == "foo") + } + + @Test func highlightTrailUsesWorktreeNameWhenPresent() { + let resolved = ResolvedRowDisplay( + kind: .gitWorktree, + branchName: "feature/foo", + worktreeName: "scratch", + isMainWorktree: false, + isPinned: false, + hideSubtitle: false, + hideSubtitleOnMatch: true, + highlightSubtitle: SidebarHighlightRepoTag(repoName: "supacode", repoColor: nil) + ) + guard case .highlight(_, _, let trail) = resolved.subtitle else { + Issue.record("Expected .highlight subtitle") + return + } + #expect(trail == "scratch") + } + + @Test func highlightTrailCollapsesToRepoWhenWorktreeNameMissing() { + let resolved = ResolvedRowDisplay( + kind: .gitWorktree, + branchName: "feature/foo", + worktreeName: nil, + isMainWorktree: false, + isPinned: false, + hideSubtitle: false, + hideSubtitleOnMatch: true, + highlightSubtitle: SidebarHighlightRepoTag(repoName: "supacode", repoColor: nil) + ) + guard case .highlight(_, _, let trail) = resolved.subtitle else { + Issue.record("Expected .highlight subtitle") + return + } + #expect(trail == nil) + } + + // MARK: - Hide-on-match parity across the two render paths. + + @Test func hideOnMatchParityBetweenHighlightAndPlainPaths() { + let plain = ResolvedRowDisplay( + kind: .gitWorktree, + branchName: "feature/foo", + worktreeName: "foo", + isMainWorktree: false, + isPinned: false, + hideSubtitle: false, + hideSubtitleOnMatch: true + ) + let highlight = ResolvedRowDisplay( + kind: .gitWorktree, + branchName: "feature/foo", + worktreeName: "foo", + isMainWorktree: false, + isPinned: true, + hideSubtitle: false, + hideSubtitleOnMatch: true, + highlightSubtitle: SidebarHighlightRepoTag(repoName: "supacode", repoColor: nil) + ) + #expect(plain.subtitle == .none) + if case .highlight(_, _, let trail) = highlight.subtitle { + #expect(trail == nil) + } else { + Issue.record("Expected .highlight subtitle for the hoisted path") + } + } + + // MARK: - Accent resolution. + + @Test func accentIsMainForMainWorktreeRegardlessOfPin() { + let resolved = ResolvedRowDisplay( + kind: .gitWorktree, + branchName: "main", + worktreeName: nil, + isMainWorktree: true, + isPinned: true, + hideSubtitle: false, + hideSubtitleOnMatch: true + ) + #expect(resolved.accent == .main) + } + + @Test func accentIsPinnedWhenPinnedAndNotMain() { + let resolved = ResolvedRowDisplay( + kind: .gitWorktree, + branchName: "feature", + worktreeName: "scratch", + isMainWorktree: false, + isPinned: true, + hideSubtitle: false, + hideSubtitleOnMatch: true + ) + #expect(resolved.accent == .pinned) + } + + @Test func accentIsDefaultWhenNeitherMainNorPinned() { + let resolved = ResolvedRowDisplay( + kind: .gitWorktree, + branchName: "feature", + worktreeName: "scratch", + isMainWorktree: false, + isPinned: false, + hideSubtitle: false, + hideSubtitleOnMatch: true + ) + #expect(resolved.accent == .default) + } +} diff --git a/supacodeTests/SidebarActiveClassificationTests.swift b/supacodeTests/SidebarActiveClassificationTests.swift new file mode 100644 index 000000000..b62d59f4d --- /dev/null +++ b/supacodeTests/SidebarActiveClassificationTests.swift @@ -0,0 +1,93 @@ +import Testing + +@testable import supacode + +@MainActor +struct SidebarActiveClassificationTests { + @Test func unreadAwaitingRunningTakesTopPriority() { + let classification = SidebarActiveClassification.classify( + hasUnread: true, hasAwaiting: true, hasAgent: true, hasRunning: true + ) + #expect(classification == .unreadAwaitingRunning) + } + + @Test func unreadAwaitingWithoutRunning() { + let classification = SidebarActiveClassification.classify( + hasUnread: true, hasAwaiting: true, hasAgent: true, hasRunning: false + ) + #expect(classification == .unreadAwaiting) + } + + @Test func unreadAgentRunningTakesPrecedenceOverUnreadAgent() { + let classification = SidebarActiveClassification.classify( + hasUnread: true, hasAwaiting: false, hasAgent: true, hasRunning: true + ) + #expect(classification == .unreadAgentRunning) + } + + @Test func unreadAgentWithoutRunning() { + let classification = SidebarActiveClassification.classify( + hasUnread: true, hasAwaiting: false, hasAgent: true, hasRunning: false + ) + #expect(classification == .unreadAgent) + } + + @Test func unreadRunningWithNoAgent() { + let classification = SidebarActiveClassification.classify( + hasUnread: true, hasAwaiting: false, hasAgent: false, hasRunning: true + ) + #expect(classification == .unreadRunning) + } + + @Test func awaitingRunningWithoutUnread() { + let classification = SidebarActiveClassification.classify( + hasUnread: false, hasAwaiting: true, hasAgent: true, hasRunning: true + ) + #expect(classification == .awaitingRunning) + } + + @Test func awaitingOnly() { + let classification = SidebarActiveClassification.classify( + hasUnread: false, hasAwaiting: true, hasAgent: true, hasRunning: false + ) + #expect(classification == .awaiting) + } + + @Test func agentRunningWithoutAwaiting() { + let classification = SidebarActiveClassification.classify( + hasUnread: false, hasAwaiting: false, hasAgent: true, hasRunning: true + ) + #expect(classification == .agentRunning) + } + + @Test func agentOnly() { + let classification = SidebarActiveClassification.classify( + hasUnread: false, hasAwaiting: false, hasAgent: true, hasRunning: false + ) + #expect(classification == .agent) + } + + @Test func runningOnly() { + let classification = SidebarActiveClassification.classify( + hasUnread: false, hasAwaiting: false, hasAgent: false, hasRunning: true + ) + #expect(classification == .running) + } + + @Test func idleRowDoesNotClassify() { + let classification = SidebarActiveClassification.classify( + hasUnread: false, hasAwaiting: false, hasAgent: false, hasRunning: false + ) + #expect(classification == nil) + } + + @Test func prioritiesOrderedAsSpec() { + // The bucket priority ordering is the user contract; lock it explicitly + // so a future shuffle of the enum case order can't silently re-rank. + let expected: [SidebarActiveClassification] = [ + .unreadAwaitingRunning, .unreadAwaiting, .unreadAgentRunning, .unreadAgent, + .unreadRunning, .awaitingRunning, .awaiting, .agentRunning, .agent, .running, + ] + #expect(SidebarActiveClassification.allCases == expected) + } +} diff --git a/supacodeTests/SidebarBottomCardTests.swift b/supacodeTests/SidebarBottomCardTests.swift index 4c69eaec2..646e21dd5 100644 --- a/supacodeTests/SidebarBottomCardTests.swift +++ b/supacodeTests/SidebarBottomCardTests.swift @@ -1,3 +1,4 @@ +import Foundation import SupacodeSettingsShared import Testing @@ -8,6 +9,7 @@ struct SidebarBottomCardTests { @Test func agentUpdatesWinOverOnboarding() { let resolved = SidebarBottomCardView.Slot.resolve( agentMode: .updatesAvailable([.claude]), + highlightMode: .visible, onboardingMode: .visible ) #expect(resolved == .agent(.updatesAvailable([.claude]))) @@ -16,22 +18,34 @@ struct SidebarBottomCardTests { @Test func agentPromptWinsOverOnboarding() { let resolved = SidebarBottomCardView.Slot.resolve( agentMode: .promptInstall, + highlightMode: .visible, onboardingMode: .visible ) #expect(resolved == .agent(.promptInstall)) } - @Test func onboardingShowsWhenAgentIsHidden() { + @Test func highlightWinsOverNestedOnboarding() { let resolved = SidebarBottomCardView.Slot.resolve( agentMode: .hidden, + highlightMode: .visible, + onboardingMode: .visible + ) + #expect(resolved == .highlightRelevantOnboarding) + } + + @Test func nestedOnboardingShowsWhenHighlightDismissed() { + let resolved = SidebarBottomCardView.Slot.resolve( + agentMode: .hidden, + highlightMode: .hidden, onboardingMode: .visible ) #expect(resolved == .nestedWorktreesOnboarding) } - @Test func noneWhenBothHidden() { + @Test func noneWhenAllHidden() { let resolved = SidebarBottomCardView.Slot.resolve( agentMode: .hidden, + highlightMode: .hidden, onboardingMode: .hidden ) #expect(resolved == SidebarBottomCardView.Slot.none) @@ -46,4 +60,65 @@ struct SidebarBottomCardTests { @Test func onboardingTransitionTokenUsesNestedWorktreesPrefix() { #expect(SidebarBottomCardView.Slot.nestedWorktreesOnboarding.transitionToken == "nestedWorktrees:visible") } + + @Test func highlightOnboardingTransitionTokenIsStable() { + #expect( + SidebarBottomCardView.Slot.highlightRelevantOnboarding.transitionToken == "highlightRelevant:visible" + ) + } + + @Test func highlightCardHiddenWhenBothTogglesOff() { + #expect( + HighlightRelevantOnboardingCardView.resolveMode( + groupPinnedRows: false, + groupActiveRows: false, + dismissedAt: .distantPast + ) == .hidden + ) + } + + @Test func highlightCardVisibleWhenOnlyPinnedOn() { + #expect( + HighlightRelevantOnboardingCardView.resolveMode( + groupPinnedRows: true, + groupActiveRows: false, + dismissedAt: .distantPast + ) == .visible + ) + } + + @Test func highlightCardVisibleWhenOnlyActiveOn() { + #expect( + HighlightRelevantOnboardingCardView.resolveMode( + groupPinnedRows: false, + groupActiveRows: true, + dismissedAt: .distantPast + ) == .visible + ) + } + + @Test func highlightCardHiddenWhenDismissedAfterRelevance() { + let afterRelevance = HighlightRelevantOnboardingCardView.cardRelevantSinceDate.addingTimeInterval(1) + #expect( + HighlightRelevantOnboardingCardView.resolveMode( + groupPinnedRows: true, + groupActiveRows: true, + dismissedAt: afterRelevance + ) == .hidden + ) + } + + @Test func highlightCardHiddenWhenDismissedAtRelevanceBoundary() { + // The relevance date must be on-or-before the ship date so a dismiss on + // release day stays sticky. A future-dated relevance date would resurface + // the card the next time SwiftUI re-rendered it. + let atBoundary = HighlightRelevantOnboardingCardView.cardRelevantSinceDate + #expect( + HighlightRelevantOnboardingCardView.resolveMode( + groupPinnedRows: true, + groupActiveRows: true, + dismissedAt: atBoundary + ) == .hidden + ) + } } diff --git a/supacodeTests/SidebarGroupingDismissTests.swift b/supacodeTests/SidebarGroupingDismissTests.swift new file mode 100644 index 000000000..795115e38 --- /dev/null +++ b/supacodeTests/SidebarGroupingDismissTests.swift @@ -0,0 +1,89 @@ +import ComposableArchitecture +import Foundation +import Sharing +import Testing + +@testable import supacode + +/// Locks the state-driven auto-dismiss for the highlight onboarding card. +/// The dismiss lives in the reducer's `.sidebarGroupingTogglesChanged` +/// handler so any path that mutates the @Shared grouping toggles fires it, +/// not just the menu binding setter. +/// +/// Each test scopes `defaultAppStorage = .inMemory` so the @Shared(.appStorage) +/// writes don't leak across the suite (a process-global UserDefaults write +/// would otherwise pollute later tests that read these keys). +@MainActor +struct SidebarGroupingDismissTests { + @Test func togglesOffWithUndismissedCardSetsDismissedAtNow() async { + let fixedDate = Date(timeIntervalSince1970: 1_800_000_000) + await withDependencies { + $0.defaultAppStorage = .inMemory + $0.date = .constant(fixedDate) + } operation: { + @Shared(.sidebarGroupPinnedRows) var pinned + @Shared(.sidebarGroupActiveRows) var active + @Shared(.appStorage("highlightRelevantOnboardingDismissedAt")) + var dismissedAt: Date = .distantPast + $pinned.withLock { $0 = false } + $active.withLock { $0 = false } + + let store = TestStore(initialState: RepositoriesFeature.State()) { + RepositoriesFeature() + } + await store.send(.sidebarGroupingTogglesChanged) + + #expect(dismissedAt == fixedDate) + } + } + + @Test func togglesOnLeavesDismissedAtUntouched() async { + let originalDismissedAt = Date(timeIntervalSince1970: 1_700_000_000) + await withDependencies { + $0.defaultAppStorage = .inMemory + $0.date = .constant(Date(timeIntervalSince1970: 1_800_000_000)) + } operation: { + @Shared(.sidebarGroupPinnedRows) var pinned + @Shared(.sidebarGroupActiveRows) var active + @Shared(.appStorage("highlightRelevantOnboardingDismissedAt")) + var dismissedAt: Date = .distantPast + $pinned.withLock { $0 = true } + $active.withLock { $0 = false } + $dismissedAt.withLock { $0 = originalDismissedAt } + + let store = TestStore(initialState: RepositoriesFeature.State()) { + RepositoriesFeature() + } + await store.send(.sidebarGroupingTogglesChanged) + + #expect(dismissedAt == originalDismissedAt) + } + } + + @Test func alreadyDismissedDoesNotOverwriteTimestamp() async { + let preexistingDismissedAt = HighlightRelevantOnboardingCardView.cardRelevantSinceDate + .addingTimeInterval(60) + await withDependencies { + $0.defaultAppStorage = .inMemory + $0.date = .constant(Date(timeIntervalSince1970: 1_900_000_000)) + } operation: { + @Shared(.sidebarGroupPinnedRows) var pinned + @Shared(.sidebarGroupActiveRows) var active + @Shared(.appStorage("highlightRelevantOnboardingDismissedAt")) + var dismissedAt: Date = .distantPast + $pinned.withLock { $0 = false } + $active.withLock { $0 = false } + $dismissedAt.withLock { $0 = preexistingDismissedAt } + + let store = TestStore(initialState: RepositoriesFeature.State()) { + RepositoriesFeature() + } + await store.send(.sidebarGroupingTogglesChanged) + + // The dismiss timestamp is preserved instead of being bumped to `.now`, + // so a user who already dismissed the card doesn't see its dismissed-at + // walk forward on every toggle flip. + #expect(dismissedAt == preexistingDismissedAt) + } + } +} diff --git a/supacodeTests/SidebarHighlightOrderingTests.swift b/supacodeTests/SidebarHighlightOrderingTests.swift new file mode 100644 index 000000000..ab59cc687 --- /dev/null +++ b/supacodeTests/SidebarHighlightOrderingTests.swift @@ -0,0 +1,87 @@ +import Testing + +@testable import supacode + +@MainActor +struct SidebarHighlightOrderingTests { + private func candidate( + _ id: String, + branch: String, + classification: SidebarActiveClassification? = nil + ) -> SidebarHighlightOrdering.Candidate { + .init(id: id, branchName: branch, classification: classification) + } + + @Test func activeDropsUnclassifiedRows() { + let ids = SidebarHighlightOrdering.orderedRowIDs( + forPinned: false, + candidates: [ + candidate("a", branch: "alpha"), + candidate("b", branch: "beta", classification: .running), + ] + ) + #expect(ids == ["b"]) + } + + @Test func pinnedKeepsUnclassifiedAtBottomAlphabetically() { + let ids = SidebarHighlightOrdering.orderedRowIDs( + forPinned: true, + candidates: [ + candidate("c", branch: "charlie"), + candidate("a", branch: "alpha"), + candidate("b", branch: "bravo", classification: .running), + ] + ) + // Classified row first (priority 10), then unclassified rows alphabetically. + #expect(ids == ["b", "a", "c"]) + } + + @Test func priorityOrdersAcrossClassifications() { + let ids = SidebarHighlightOrdering.orderedRowIDs( + forPinned: false, + candidates: [ + candidate("running", branch: "running", classification: .running), + candidate("unreadAwaiting", branch: "unread-awaiting", classification: .unreadAwaiting), + candidate("agent", branch: "agent", classification: .agent), + candidate("unreadAwaitingRunning", branch: "top", classification: .unreadAwaitingRunning), + ] + ) + #expect(ids == ["unreadAwaitingRunning", "unreadAwaiting", "agent", "running"]) + } + + @Test func alphabeticalTieBreakIsLocaleInsensitive() { + // Same priority bucket; tie-break must be locale-insensitive alphabetical + // on branch name so "Bravo" and "bravo" don't flip when the user has + // different system locales. + let ids = SidebarHighlightOrdering.orderedRowIDs( + forPinned: false, + candidates: [ + candidate("z", branch: "Zulu", classification: .running), + candidate("a", branch: "alpha", classification: .running), + candidate("b", branch: "Bravo", classification: .running), + ] + ) + #expect(ids == ["a", "b", "z"]) + } + + @Test func pinnedAndActiveDoNotDuplicate() { + // Active section drops rows that are already in Pinned, so the same + // worktree never renders twice in the highlight region. The aggregator + // performs the dedup before calling this helper (via the `excluding` + // set on the view side); locking the pure helper's behavior here. + let candidates: [SidebarHighlightOrdering.Candidate] = [ + candidate("shared", branch: "shared", classification: .running), + candidate("active-only", branch: "active", classification: .agent), + ] + let activeIDs = SidebarHighlightOrdering.orderedRowIDs( + forPinned: false, + candidates: candidates.filter { $0.id != "shared" } + ) + #expect(activeIDs == ["active-only"]) + } + + @Test func emptyCandidatesYieldEmptyOrder() { + #expect(SidebarHighlightOrdering.orderedRowIDs(forPinned: true, candidates: []) == []) + #expect(SidebarHighlightOrdering.orderedRowIDs(forPinned: false, candidates: []) == []) + } +} diff --git a/supacodeTests/SidebarPersistenceKeyTests.swift b/supacodeTests/SidebarPersistenceKeyTests.swift index 645ec2372..85c7b3dcb 100644 --- a/supacodeTests/SidebarPersistenceKeyTests.swift +++ b/supacodeTests/SidebarPersistenceKeyTests.swift @@ -10,6 +10,16 @@ import Testing @MainActor struct SidebarPersistenceKeyTests { + @Test func groupHighlightRowsDefaultsOn() { + // First-launch discoverability contract for the View-menu submenu: both + // Group Pinned Rows and Group Active Rows must be visible by default so + // users see the highlight feature without opening the menu. + @Shared(.sidebarGroupPinnedRows) var groupPinned + @Shared(.sidebarGroupActiveRows) var groupActive + #expect(groupPinned == true) + #expect(groupActive == true) + } + @Test func corruptFileIsRenamedBeforeFallback() async throws { // Write the corrupt bytes to an isolated temp directory so the // test never touches the user's real `~/.supacode/sidebar.json`. diff --git a/supacodeTests/SidebarStructureTests.swift b/supacodeTests/SidebarStructureTests.swift new file mode 100644 index 000000000..51e014bb9 --- /dev/null +++ b/supacodeTests/SidebarStructureTests.swift @@ -0,0 +1,749 @@ +import ComposableArchitecture +import Foundation +import IdentifiedCollections +import OrderedCollections +import Sharing +import SwiftUI +import Testing + +@testable import SupacodeSettingsShared +@testable import supacode + +/// Integration coverage for `RepositoriesFeature.State.computeSidebarStructure(...)`. +/// The pure helpers (`SidebarHighlightOrdering`, `SidebarActiveClassification`) have +/// their own unit suites; this file locks the contract on how they fuse: section +/// ordering, dedupe, hotkey numbering, placeholder mode, failed-repo positioning, +/// and the across-bucket dedupe inside `SidebarItemGroup.computeSlots`. +@MainActor +struct SidebarStructureTests { + // MARK: - Helpers. + + private func makeWorktree(id: String, name: String, repoRoot: URL) -> Worktree { + Worktree( + id: id, + name: name, + detail: "", + workingDirectory: URL(fileURLWithPath: id), + repositoryRootURL: repoRoot + ) + } + + private func makeMainWorktree(repoRoot: URL) -> Worktree { + Worktree( + id: repoRoot.path(percentEncoded: false), + name: "main", + detail: "", + workingDirectory: repoRoot, + repositoryRootURL: repoRoot + ) + } + + private func makeState(repositories: [Repository]) -> RepositoriesFeature.State { + var state = RepositoriesFeature.State(reconciledRepositories: repositories) + state.isInitialLoadComplete = true + return state + } + + // MARK: - Placeholder mode. + + @Test func placeholderModeEmitsPlaceholderSectionAndEmptyHotkeys() { + var state = RepositoriesFeature.State() + state.isInitialLoadComplete = false + state.repositories = [] + + let structure = state.computeSidebarStructure(groupPinned: true, groupActive: true) + + #expect(structure.sections == [.placeholder]) + #expect(structure.hoistedRowIDs.isEmpty) + #expect(structure.hotkeySlots.isEmpty) + #expect(structure.slotByID.isEmpty) + #expect(structure.repositoryHighlightByID.isEmpty) + #expect(structure.reorderableRepositoryIDs.isEmpty) + } + + // MARK: - Both toggles off → no hoisting. + + @Test func bothTogglesOffProducesNoHighlights() { + let repoRoot = URL(fileURLWithPath: "/tmp/repo") + let main = makeMainWorktree(repoRoot: repoRoot) + let feature = makeWorktree(id: "/tmp/repo/wt", name: "feature", repoRoot: repoRoot) + let repository = Repository( + id: repoRoot.path(percentEncoded: false), + rootURL: repoRoot, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [main, feature]) + ) + let state = makeState(repositories: [repository]) + + let structure = state.computeSidebarStructure(groupPinned: false, groupActive: false) + + let highlightKinds = structure.sections.compactMap { section -> SidebarStructure.HighlightKind? in + if case .highlight(let kind, _) = section { return kind } + return nil + } + #expect(highlightKinds.isEmpty) + #expect(structure.hoistedRowIDs.isEmpty) + } + + // MARK: - Pinned hoisting + git main exclusion. + + @Test func gitMainWorktreeNeverEntersPinnedHighlight() { + let repoRoot = URL(fileURLWithPath: "/tmp/repo") + let main = makeMainWorktree(repoRoot: repoRoot) + let repository = Repository( + id: repoRoot.path(percentEncoded: false), + rootURL: repoRoot, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [main]) + ) + var state = makeState(repositories: [repository]) + // Even if some pre-state has the main in `.pinned`, the helper must skip it. + state.$sidebar.withLock { sidebar in + var section = sidebar.sections[repository.id] ?? .init() + var pinnedBucket = section.buckets[.pinned] ?? .init() + pinnedBucket.items[main.id] = .init() + section.buckets[.pinned] = pinnedBucket + sidebar.sections[repository.id] = section + } + + let structure = state.computeSidebarStructure(groupPinned: true, groupActive: true) + + let pinnedIDs = structure.sections.compactMap { section -> [Worktree.ID]? in + if case .highlight(.pinned, let ids) = section { return ids } + return nil + }.flatMap { $0 } + #expect(pinnedIDs.isEmpty) + #expect(!structure.hoistedRowIDs.contains(main.id)) + } + + // MARK: - Hotkey order dedupes hoisted rows. + + @Test func hotkeyOrderDoesNotIncludeHoistedRowsTwice() { + let repoRoot = URL(fileURLWithPath: "/tmp/repo") + let main = makeMainWorktree(repoRoot: repoRoot) + let pinned = makeWorktree(id: "/tmp/repo/pinned", name: "pinned", repoRoot: repoRoot) + let extra = makeWorktree(id: "/tmp/repo/extra", name: "extra", repoRoot: repoRoot) + let repository = Repository( + id: repoRoot.path(percentEncoded: false), + rootURL: repoRoot, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [main, pinned, extra]) + ) + var state = makeState(repositories: [repository]) + // Pin `pinned` so it qualifies for the Pinned highlight section. + state.$sidebar.withLock { sidebar in + var section = sidebar.sections[repository.id] ?? .init() + var pinnedBucket = section.buckets[.pinned] ?? .init() + pinnedBucket.items[pinned.id] = .init() + section.buckets[.pinned] = pinnedBucket + sidebar.sections[repository.id] = section + } + + let structure = state.computeSidebarStructure(groupPinned: true, groupActive: false) + + let hotkeyIDs = structure.hotkeySlots.map(\.id) + #expect(hotkeyIDs.filter { $0 == pinned.id }.count == 1) + #expect(structure.slotByID[pinned.id] != nil) + // Pinned hoist appears before per-repo main in the visible top-down order. + let pinnedSlot = structure.slotByID[pinned.id] ?? -1 + let mainSlot = structure.slotByID[main.id] ?? -1 + #expect(pinnedSlot < mainSlot) + } + + // MARK: - Per-bucket dedupe. + + @Test func computeSlotsDedupesAcrossPinnedAndUnpinnedBuckets() { + let repoRoot = URL(fileURLWithPath: "/tmp/repo") + let main = makeMainWorktree(repoRoot: repoRoot) + let duplicate = makeWorktree(id: "/tmp/repo/dup", name: "duplicate", repoRoot: repoRoot) + let repository = Repository( + id: repoRoot.path(percentEncoded: false), + rootURL: repoRoot, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [main, duplicate]) + ) + var state = makeState(repositories: [repository]) + // Hand-edit pre-state so `duplicate` lives in BOTH .pinned and .unpinned. + state.$sidebar.withLock { sidebar in + var section = sidebar.sections[repository.id] ?? .init() + var pinnedBucket = section.buckets[.pinned] ?? .init() + pinnedBucket.items[duplicate.id] = .init() + section.buckets[.pinned] = pinnedBucket + var unpinnedBucket = section.buckets[.unpinned] ?? .init() + unpinnedBucket.items[duplicate.id] = .init() + section.buckets[.unpinned] = unpinnedBucket + sidebar.sections[repository.id] = section + } + + let groups = SidebarItemGroup.computeSlots( + in: state, + repositoryID: repository.id, + pendingIDs: [], + hoistedRowIDs: [], + nestWorktreesByBranch: false + ) + let allRowIDs = groups.flatMap { $0.rowIDs } + #expect(allRowIDs.filter { $0 == duplicate.id }.count == 1) + } + + // MARK: - Branch nesting alphabetical sort. + + @Test func nestByBranchSortsPinnedAndUnpinnedTailsAlphabetically() { + let repoRoot = URL(fileURLWithPath: "/tmp/repo") + let main = makeMainWorktree(repoRoot: repoRoot) + let charlie = makeWorktree(id: "/tmp/repo/charlie", name: "charlie", repoRoot: repoRoot) + let alpha = makeWorktree(id: "/tmp/repo/alpha", name: "alpha", repoRoot: repoRoot) + let bravo = makeWorktree(id: "/tmp/repo/bravo", name: "bravo", repoRoot: repoRoot) + let unpinX = makeWorktree(id: "/tmp/repo/x", name: "x-branch", repoRoot: repoRoot) + let unpinB = makeWorktree(id: "/tmp/repo/b", name: "b-branch", repoRoot: repoRoot) + let repository = Repository( + id: repoRoot.path(percentEncoded: false), + rootURL: repoRoot, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [main, charlie, alpha, bravo, unpinB, unpinX]) + ) + var state = makeState(repositories: [repository]) + // Pin charlie, alpha, bravo in bucket order DIFFERENT from alphabetical. + state.$sidebar.withLock { sidebar in + var section = sidebar.sections[repository.id] ?? .init() + var pinnedBucket = section.buckets[.pinned] ?? .init() + pinnedBucket.items[charlie.id] = .init() + pinnedBucket.items[alpha.id] = .init() + pinnedBucket.items[bravo.id] = .init() + section.buckets[.pinned] = pinnedBucket + var unpinnedBucket = section.buckets[.unpinned] ?? .init() + unpinnedBucket.items.removeValue(forKey: charlie.id) + unpinnedBucket.items.removeValue(forKey: alpha.id) + unpinnedBucket.items.removeValue(forKey: bravo.id) + section.buckets[.unpinned] = unpinnedBucket + sidebar.sections[repository.id] = section + } + for id in [alpha.id, bravo.id, charlie.id, unpinX.id, unpinB.id] { + let name = state.sidebarItems[id: id]?.name ?? id + state.sidebarItems[id: id]?.branchName = name + } + state.reconcileSidebarForTesting() + + let groups = SidebarItemGroup.computeSlots( + in: state, + repositoryID: repository.id, + pendingIDs: [], + hoistedRowIDs: [], + nestWorktreesByBranch: true + ) + + let pinnedTail = groups.first { $0.slot == .pinnedTail }?.rowIDs ?? [] + let unpinnedTail = groups.first { $0.slot == .unpinnedTail }?.rowIDs ?? [] + #expect(pinnedTail == [alpha.id, bravo.id, charlie.id]) + #expect(unpinnedTail == [unpinB.id, unpinX.id]) + } + + @Test func nestByBranchOffPreservesBucketOrder() { + let repoRoot = URL(fileURLWithPath: "/tmp/repo") + let main = makeMainWorktree(repoRoot: repoRoot) + let charlie = makeWorktree(id: "/tmp/repo/charlie", name: "charlie", repoRoot: repoRoot) + let alpha = makeWorktree(id: "/tmp/repo/alpha", name: "alpha", repoRoot: repoRoot) + let repository = Repository( + id: repoRoot.path(percentEncoded: false), + rootURL: repoRoot, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [main, charlie, alpha]) + ) + var state = makeState(repositories: [repository]) + state.$sidebar.withLock { sidebar in + var section = sidebar.sections[repository.id] ?? .init() + var pinnedBucket = section.buckets[.pinned] ?? .init() + pinnedBucket.items[charlie.id] = .init() + pinnedBucket.items[alpha.id] = .init() + section.buckets[.pinned] = pinnedBucket + var unpinnedBucket = section.buckets[.unpinned] ?? .init() + unpinnedBucket.items.removeValue(forKey: charlie.id) + unpinnedBucket.items.removeValue(forKey: alpha.id) + section.buckets[.unpinned] = unpinnedBucket + sidebar.sections[repository.id] = section + } + state.reconcileSidebarForTesting() + + let groups = SidebarItemGroup.computeSlots( + in: state, + repositoryID: repository.id, + pendingIDs: [], + hoistedRowIDs: [], + nestWorktreesByBranch: false + ) + let pinnedTail = groups.first { $0.slot == .pinnedTail }?.rowIDs ?? [] + #expect(pinnedTail == [charlie.id, alpha.id]) + } + + @Test func hotkeySlotsFollowAlphabeticalOrderWhenNestByBranchOn() { + let repoRoot = URL(fileURLWithPath: "/tmp/repo") + let main = makeMainWorktree(repoRoot: repoRoot) + let charlie = makeWorktree(id: "/tmp/repo/charlie", name: "charlie", repoRoot: repoRoot) + let alpha = makeWorktree(id: "/tmp/repo/alpha", name: "alpha", repoRoot: repoRoot) + let bravo = makeWorktree(id: "/tmp/repo/bravo", name: "bravo", repoRoot: repoRoot) + let repository = Repository( + id: repoRoot.path(percentEncoded: false), + rootURL: repoRoot, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [main, charlie, alpha, bravo]) + ) + var state = makeState(repositories: [repository]) + state.$sidebar.withLock { sidebar in + var section = sidebar.sections[repository.id] ?? .init() + var pinnedBucket = section.buckets[.pinned] ?? .init() + pinnedBucket.items[charlie.id] = .init() + pinnedBucket.items[alpha.id] = .init() + pinnedBucket.items[bravo.id] = .init() + section.buckets[.pinned] = pinnedBucket + var unpinnedBucket = section.buckets[.unpinned] ?? .init() + unpinnedBucket.items.removeValue(forKey: charlie.id) + unpinnedBucket.items.removeValue(forKey: alpha.id) + unpinnedBucket.items.removeValue(forKey: bravo.id) + section.buckets[.unpinned] = unpinnedBucket + sidebar.sections[repository.id] = section + } + for id in [alpha.id, bravo.id, charlie.id] { + let name = state.sidebarItems[id: id]?.name ?? id + state.sidebarItems[id: id]?.branchName = name + } + state.$sidebarNestWorktreesByBranch.withLock { $0 = true } + state.reconcileSidebarForTesting() + + let structure = state.computeSidebarStructure(groupPinned: false, groupActive: false) + + let expectedOrderAfterMain = [alpha.id, bravo.id, charlie.id] + let mainSlot = structure.slotByID[main.id] + let alphaSlot = structure.slotByID[alpha.id] + let bravoSlot = structure.slotByID[bravo.id] + let charlieSlot = structure.slotByID[charlie.id] + #expect(mainSlot == 0) + #expect(alphaSlot == 1) + #expect(bravoSlot == 2) + #expect(charlieSlot == 3) + #expect(structure.hotkeySlots.map(\.id) == [main.id] + expectedOrderAfterMain) + } + + // MARK: - Active classification. + + @Test func qualifyingRowsLandInActiveAndNotInPerRepoTail() { + let repoRoot = URL(fileURLWithPath: "/tmp/repo") + let main = makeMainWorktree(repoRoot: repoRoot) + let busy = makeWorktree(id: "/tmp/repo/busy", name: "busy", repoRoot: repoRoot) + let idle = makeWorktree(id: "/tmp/repo/idle", name: "idle", repoRoot: repoRoot) + let repository = Repository( + id: repoRoot.path(percentEncoded: false), + rootURL: repoRoot, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [main, busy, idle]) + ) + var state = makeState(repositories: [repository]) + // `runningScripts` non-empty is the simplest single flag that classifies + // a row (unread alone returns nil, needs to be paired with another flag). + state.sidebarItems[id: busy.id]?.runningScripts.append(.init(id: UUID(), tint: .blue)) + + let structure = state.computeSidebarStructure(groupPinned: true, groupActive: true) + + let activeIDs = structure.sections.compactMap { section -> [Worktree.ID]? in + if case .highlight(.active, let ids) = section { return ids } + return nil + }.flatMap { $0 } + #expect(activeIDs == [busy.id]) + #expect(structure.hoistedRowIDs.contains(busy.id)) + // The hoisted row doesn't double-render in the repository section's tail. + let perRepoTailIDs = structure.sections.compactMap { section -> [Worktree.ID]? in + if case .repository(_, let groups) = section { + return groups.flatMap(\.rowIDs) + } + return nil + }.flatMap { $0 } + #expect(!perRepoTailIDs.contains(busy.id)) + } + + // MARK: - Archived filter. + + @Test func archivedRowsExcludedFromBothHighlights() { + let repoRoot = URL(fileURLWithPath: "/tmp/repo") + let main = makeMainWorktree(repoRoot: repoRoot) + let archived = makeWorktree(id: "/tmp/repo/archived", name: "archived", repoRoot: repoRoot) + let repository = Repository( + id: repoRoot.path(percentEncoded: false), + rootURL: repoRoot, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [main, archived]) + ) + var state = makeState(repositories: [repository]) + state.sidebarItems[id: archived.id]?.hasUnseenNotifications = true + // Mark the row as archived; structure must skip it from both highlights. + state.$sidebar.withLock { sidebar in + var section = sidebar.sections[repository.id] ?? .init() + var archivedBucket = section.buckets[.archived] ?? .init() + archivedBucket.items[archived.id] = .init(archivedAt: Date(timeIntervalSince1970: 0)) + section.buckets[.archived] = archivedBucket + sidebar.sections[repository.id] = section + } + + let structure = state.computeSidebarStructure(groupPinned: true, groupActive: true) + #expect(!structure.hoistedRowIDs.contains(archived.id)) + } + + // MARK: - Failed repository section placement. + + @Test func failedRepositorySectionEmittedAtRepositoryRootPosition() { + let repoRoot = URL(fileURLWithPath: "/tmp/repo") + let main = makeMainWorktree(repoRoot: repoRoot) + let repository = Repository( + id: repoRoot.path(percentEncoded: false), + rootURL: repoRoot, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [main]) + ) + var state = makeState(repositories: [repository]) + let failedRoot = URL(fileURLWithPath: "/tmp/broken") + let failedID = failedRoot.path(percentEncoded: false) + state.repositoryRoots.append(failedRoot) + state.loadFailuresByID[failedID] = "boom" + + let structure = state.computeSidebarStructure(groupPinned: false, groupActive: false) + + let failedIndex = structure.sections.firstIndex { + if case .failedRepository(let id, _, _) = $0 { return id == failedID } + return false + } + let repoIndex = structure.sections.firstIndex { + if case .repository(let id, _) = $0 { return id == repository.id } + return false + } + #expect(failedIndex != nil) + #expect(repoIndex != nil) + #expect(structure.reorderableRepositoryIDs.contains(failedID)) + } + + // MARK: - Custom repo title flows through to the highlight tag. + + @Test func highlightTagReadsCustomRepoTitleAndColor() { + let repoRoot = URL(fileURLWithPath: "/tmp/repo") + let main = makeMainWorktree(repoRoot: repoRoot) + let busy = makeWorktree(id: "/tmp/repo/busy", name: "busy", repoRoot: repoRoot) + let repository = Repository( + id: repoRoot.path(percentEncoded: false), + rootURL: repoRoot, + name: "raw-folder-name", + worktrees: IdentifiedArray(uniqueElements: [main, busy]) + ) + var state = makeState(repositories: [repository]) + state.sidebarItems[id: busy.id]?.runningScripts.append(.init(id: UUID(), tint: .blue)) + state.$sidebar.withLock { sidebar in + sidebar.sections[repository.id, default: .init()].title = " Pretty Name " + sidebar.sections[repository.id, default: .init()].color = .purple + } + + let structure = state.computeSidebarStructure(groupPinned: true, groupActive: true) + + let tag = structure.repositoryHighlightByID[repository.id] + #expect(tag?.repoName == "Pretty Name") + #expect(tag?.repoColor == .purple) + } + + @Test func highlightTagFallsBackToRepositoryNameOnEmptyCustomTitle() { + let repoRoot = URL(fileURLWithPath: "/tmp/repo") + let main = makeMainWorktree(repoRoot: repoRoot) + let busy = makeWorktree(id: "/tmp/repo/busy", name: "busy", repoRoot: repoRoot) + let repository = Repository( + id: repoRoot.path(percentEncoded: false), + rootURL: repoRoot, + name: "fallback-name", + worktrees: IdentifiedArray(uniqueElements: [main, busy]) + ) + var state = makeState(repositories: [repository]) + state.sidebarItems[id: busy.id]?.runningScripts.append(.init(id: UUID(), tint: .blue)) + state.$sidebar.withLock { sidebar in + sidebar.sections[repository.id, default: .init()].title = " " + } + + let structure = state.computeSidebarStructure(groupPinned: true, groupActive: true) + + #expect(structure.repositoryHighlightByID[repository.id]?.repoName == "fallback-name") + } + + // MARK: - Lifecycle filter excludes terminating rows from Active. + + @Test func archivingRowIsExcludedFromActive() { + let repoRoot = URL(fileURLWithPath: "/tmp/repo") + let main = makeMainWorktree(repoRoot: repoRoot) + let archiving = makeWorktree(id: "/tmp/repo/archiving", name: "archiving", repoRoot: repoRoot) + let repository = Repository( + id: repoRoot.path(percentEncoded: false), + rootURL: repoRoot, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [main, archiving]) + ) + var state = makeState(repositories: [repository]) + state.sidebarItems[id: archiving.id]?.runningScripts.append(.init(id: UUID(), tint: .blue)) + state.sidebarItems[id: archiving.id]?.lifecycle = .archiving + + let structure = state.computeSidebarStructure(groupPinned: true, groupActive: true) + + let activeIDs = structure.sections.compactMap { section -> [Worktree.ID]? in + if case .highlight(.active, let ids) = section { return ids } + return nil + }.flatMap { $0 } + #expect(!activeIDs.contains(archiving.id)) + #expect(!structure.hoistedRowIDs.contains(archiving.id)) + } + + @Test func deletingRowIsExcludedFromActive() { + let repoRoot = URL(fileURLWithPath: "/tmp/repo") + let main = makeMainWorktree(repoRoot: repoRoot) + let deleting = makeWorktree(id: "/tmp/repo/deleting", name: "deleting", repoRoot: repoRoot) + let repository = Repository( + id: repoRoot.path(percentEncoded: false), + rootURL: repoRoot, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [main, deleting]) + ) + var state = makeState(repositories: [repository]) + state.sidebarItems[id: deleting.id]?.runningScripts.append(.init(id: UUID(), tint: .blue)) + state.sidebarItems[id: deleting.id]?.lifecycle = .deleting + + let structure = state.computeSidebarStructure(groupPinned: true, groupActive: true) + + let activeIDs = structure.sections.compactMap { section -> [Worktree.ID]? in + if case .highlight(.active, let ids) = section { return ids } + return nil + }.flatMap { $0 } + #expect(!activeIDs.contains(deleting.id)) + } + + @Test func pendingRowWithRunningScriptStaysEligibleForActive() { + let repoRoot = URL(fileURLWithPath: "/tmp/repo") + let main = makeMainWorktree(repoRoot: repoRoot) + let pending = makeWorktree(id: "/tmp/repo/pending", name: "pending", repoRoot: repoRoot) + let repository = Repository( + id: repoRoot.path(percentEncoded: false), + rootURL: repoRoot, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [main, pending]) + ) + var state = makeState(repositories: [repository]) + state.sidebarItems[id: pending.id]?.runningScripts.append(.init(id: UUID(), tint: .blue)) + state.sidebarItems[id: pending.id]?.lifecycle = .pending + + let structure = state.computeSidebarStructure(groupPinned: true, groupActive: true) + + let activeIDs = structure.sections.compactMap { section -> [Worktree.ID]? in + if case .highlight(.active, let ids) = section { return ids } + return nil + }.flatMap { $0 } + #expect(activeIDs.contains(pending.id)) + } + + // MARK: - Git main detected at any pinned-bucket position. + + @Test func gitMainAtNonZeroPinnedIndexStillRoutesToMainSlot() { + let repoRoot = URL(fileURLWithPath: "/tmp/repo") + let main = makeMainWorktree(repoRoot: repoRoot) + let other = makeWorktree(id: "/tmp/repo/other", name: "other", repoRoot: repoRoot) + let repository = Repository( + id: repoRoot.path(percentEncoded: false), + rootURL: repoRoot, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [main, other]) + ) + var state = makeState(repositories: [repository]) + // Corrupted pre-state: main lives at index 1 of `.pinned`, not 0. We + // bypass `rebuildSidebarGrouping` (which would re-seed main at index 0) + // by writing directly to `state.sidebarGrouping`. + var bucket = SidebarGrouping.BucketGrouping() + bucket[.pinned] = [other.id, main.id] + bucket[.unpinned] = [] + bucket[.archived] = [] + state.sidebarGrouping = SidebarGrouping(bucketsByRepository: [repository.id: bucket]) + + let groups = SidebarItemGroup.computeSlots( + in: state, + repositoryID: repository.id, + pendingIDs: [], + hoistedRowIDs: [], + nestWorktreesByBranch: false + ) + let mainGroup = groups.first { if case .main = $0.slot { return true } else { return false } } + let pinnedTail = groups.first { if case .pinnedTail = $0.slot { return true } else { return false } } + #expect(mainGroup?.rowIDs == [main.id]) + #expect(pinnedTail?.rowIDs == [other.id]) + } + + // MARK: - Folder hoist drops the folder section. + + @Test func folderRowHoistedIntoHighlightIsOmittedFromItsFolderSection() { + let folderURL = URL(fileURLWithPath: "/tmp/folder") + let folderID = Repository.folderWorktreeID(for: folderURL) + let folderRepo = Repository( + id: folderURL.path(percentEncoded: false), + rootURL: folderURL, + name: "folder", + worktrees: IdentifiedArray( + uniqueElements: [ + Worktree( + id: folderID, + name: "folder", + detail: "", + workingDirectory: folderURL, + repositoryRootURL: folderURL + ) + ] + ), + isGitRepository: false + ) + var state = makeState(repositories: [folderRepo]) + state.$sidebar.withLock { sidebar in + var section = sidebar.sections[folderRepo.id] ?? .init() + var pinnedBucket = section.buckets[.pinned] ?? .init() + pinnedBucket.items[folderID] = .init() + section.buckets[.pinned] = pinnedBucket + // Remove the default `.unpinned` seed so the row only lives in `.pinned`. + section.buckets[.unpinned]?.items.removeValue(forKey: folderID) + sidebar.sections[folderRepo.id] = section + } + + let structure = state.computeSidebarStructure(groupPinned: true, groupActive: false) + + let hasFolderSection = structure.sections.contains { + if case .folder(_, let id) = $0 { return id == folderID } + return false + } + let pinnedIDs = structure.sections.compactMap { section -> [Worktree.ID]? in + if case .highlight(.pinned, let ids) = section { return ids } + return nil + }.flatMap { $0 } + #expect(pinnedIDs == [folderID]) + #expect(!hasFolderSection) + } + + // MARK: - SidebarItemGroup.translateFilteredMove. + + @Test func translateFilteredMoveMapsAcrossHoistedRows() { + let full = ["a", "b", "c", "d", "e"] + let visible = ["a", "b", "d", "e"] // c is hoisted. + + // Move visible offset 2 (d) to visible offset 0 (before a). + let result = SidebarItemGroup.translateFilteredMove( + offsets: IndexSet([2]), + destination: 0, + visibleIDs: visible, + fullIDs: full + ) + #expect(result?.offsets == IndexSet([3])) + #expect(result?.destination == 0) + } + + @Test func translateFilteredMoveDestinationPastEndMapsToFullEnd() { + let full = ["a", "b", "c", "d"] + let visible = ["a", "c", "d"] // b is hoisted. + + let result = SidebarItemGroup.translateFilteredMove( + offsets: IndexSet([0]), + destination: visible.count, + visibleIDs: visible, + fullIDs: full + ) + #expect(result?.offsets == IndexSet([0])) + #expect(result?.destination == full.count) + } + + @Test func translateFilteredMoveReturnsNilForOutOfRangeOffset() { + let full = ["a", "b", "c"] + let visible = ["a", "c"] + + let result = SidebarItemGroup.translateFilteredMove( + offsets: IndexSet([5]), + destination: 0, + visibleIDs: visible, + fullIDs: full + ) + #expect(result == nil) + } + + @Test func translateFilteredMoveReturnsNilForOutOfRangeDestination() { + let full = ["a", "b", "c"] + let visible = ["a", "c"] + + let result = SidebarItemGroup.translateFilteredMove( + offsets: IndexSet([0]), + destination: 99, + visibleIDs: visible, + fullIDs: full + ) + #expect(result == nil) + } + + @Test func translateFilteredMoveReturnsNilWhenVisibleHasIDNotInFull() { + let full = ["a", "b"] + let visible = ["a", "ghost"] // "ghost" isn't in full. + + let result = SidebarItemGroup.translateFilteredMove( + offsets: IndexSet([1]), + destination: 0, + visibleIDs: visible, + fullIDs: full + ) + #expect(result == nil) + } + + @Test func translateFilteredMoveAppliedYieldsExpectedFullOrder() { + let full = ["a", "b", "c", "d", "e"] + let visible = ["a", "b", "d", "e"] // c is hoisted. + + // Drag b (visible 1) past d (to before e, visible 3). + let translated = SidebarItemGroup.translateFilteredMove( + offsets: IndexSet([1]), + destination: 3, + visibleIDs: visible, + fullIDs: full + ) + #expect(translated != nil) + guard let translated else { return } + + var reordered = full + reordered.move(fromOffsets: translated.offsets, toOffset: translated.destination) + // Hoisted c stays put relative to its neighbors; b lands before e. + #expect(reordered == ["a", "c", "d", "b", "e"]) + } + + @Test func translateFilteredMoveHandlesEmptyOffsets() { + let full = ["a", "b"] + let visible = ["a", "b"] + + let result = SidebarItemGroup.translateFilteredMove( + offsets: IndexSet(), + destination: 1, + visibleIDs: visible, + fullIDs: full + ) + #expect(result?.offsets == IndexSet()) + #expect(result?.destination == 1) + } + + @Test func translateFilteredMoveLastVisibleIndexMapsBeforeHoistedTail() { + // Inclusive upper-bound test: visible's last index (NOT past-end) when + // followed by a hoisted tail row must map to its own full index, not the + // full-end. Drops the dragged row before the hoisted tail, not after. + let full = ["a", "b", "c", "d"] // d is hoisted. + let visible = ["a", "b", "c"] + + let translated = SidebarItemGroup.translateFilteredMove( + offsets: IndexSet([0]), + destination: visible.count - 1, + visibleIDs: visible, + fullIDs: full + ) + #expect(translated != nil) + guard let translated else { return } + #expect(translated.offsets == IndexSet([0])) + #expect(translated.destination == 2) + + var reordered = full + reordered.move(fromOffsets: translated.offsets, toOffset: translated.destination) + // Hoisted d stays last; a moves to just before c. + #expect(reordered == ["b", "a", "c", "d"]) + } +}