Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,18 +133,32 @@ Reducer ← .repositories(.worktreeInfoEvent(Event)) ← AsyncStream<Event>
## Sidebar performance

- Per-row `SidebarItemFeature` state lives in `RepositoriesFeature.State.sidebarItems: IdentifiedArrayOf<SidebarItemFeature.State>` (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<RepositoriesFeature>` + `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<RepositoriesFeature>` + `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)
Expand Down
42 changes: 29 additions & 13 deletions supacode/App/SidebarBottomCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,42 +5,59 @@ 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<AppFeature>
@Shared(.appStorage("codingAgentsSetupCardDismissedAt"))
private var agentDismissedAt: Date = .distantPast
@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:
EmptyView()
case .agent(let mode):
CodingAgentsSidebarCardView(store: store, mode: mode)
.transition(Slot.transition)
case .highlightRelevantOnboarding:
HighlightRelevantOnboardingCardView()
.transition(Slot.transition)
case .nestedWorktreesOnboarding:
NestedWorktreesOnboardingCardView()
.transition(Slot.transition)
Expand All @@ -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
}

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