native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add folder (non-git) repository support + harden removal pipeline (#257)

* Add folder (non-git) repository support

Classify repositories as git or folder at load time via
`Repository.isGitRepository(at:)` (checks `.git` dir/file and the
`.bare` / `.git` root-name conventions). Classification runs through
`GitClientDependency.isGitRepository` so tests can override 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.

Folder-remove alert offers three buttons: "Remove from Supacode"
(stop managing, disk untouched), "Delete from disk" (routes through
`FileManager.trashItem`), and "Cancel". Both routes flow through
the existing `.requestDeleteWorktree` →
`.deleteWorktreeConfirmed` → `.deleteScriptCompleted` pipeline;
handlers branch on intent so `gitClient.removeWorktree` is never
called for folders, and `removingRepositoryIDs: [Repository.ID:
RemovalIntent]` carries the routing intent even if a `git init`
flips `isGitRepository` mid-delete (the kind flip surfaces a
user-visible error).

Settings hides Setup and Archive script sections for folders;
Delete Script and user-defined scripts stay. `openRepositorySettings`
(context menu + deeplink) routes folders to `.repositoryScripts`
since there is no general pane for them.

Guards added where git-only behavior would be wrong:
`worktreesForInfoWatcher()` filters out folders; command palette
dedupes folder rows to `Foo` instead of `Foo / Foo`; worktree
deeplinks (`.archive`, `.unarchive`, `.pin`, `.unpin`) reject folder
targets with an explanatory alert;
`createRandomWorktreeInRepository` /
`createWorktreeInRepository` / `.repoWorktreeNew` deeplink reject
folders up front. `repository_removed` analytics is tagged with
`kind`. UI copy flipped to "Add Repository or Folder" across menus,
buttons, tooltips, and shortcut help.

Tests cover classification, immediate appearance after
`.openRepositories`, mixed git+folder load, watcher filter, both
deletion paths (`.requestRemoveRepository` and
`.requestDeleteWorktree`), blocking-script success + failure +
cancellation, the kind-flip race, the folder-guard on
new-worktree creation, the unified removal intent, and the
Delete-from-disk path. `AGENTS.md` documents the invariants.

* Refactor sidebar UI into shared SidebarItem views

Route folder repositories through the same `SidebarItemView` /
`SidebarItemsView` / `SidebarItemContextMenu` stack as git
worktrees by introducing `SidebarItemModel.Kind` (.git / .folder).
Folders inherit the standard shimmer, running-script ping dot,
notification badge, and archiving/deleting icon flips for free;
`SidebarItemView` short-circuits on `.folder` to render an SF
`folder` icon at `.fontWeight(.semibold)` and skip branch-name /
PR / check-badge computation.

Rename `WorktreeRowModel` / `WorktreeRow` / `WorktreeRowsView` /
`WorktreeRowGroupView` / `WorktreeRowContainer` /
`WorktreeContextMenu` / `WorktreeRowSections` to
`SidebarItemModel` / `SidebarItemView` / `SidebarItemsView` /
`SidebarItemGroupView` / `SidebarItemContainer` /
`SidebarItemContextMenu` / `SidebarItemSections`, and promote
`SidebarRepositorySectionView` to `SidebarSectionView` behind a
new `SidebarRootView` dispatcher. The context menu renders
folder-specific copy inline ("Remove Folder…", "Archive Folder…"
disabled, "Folder Settings…") and drops "Copy as Branch Name".

Folder sidebar sections wrap `SidebarItemsView` in a `Section { … }
header: { EmptyView() }` so the sidebar applies the same
inter-group padding used between git sections; the explicit
`header:` form keeps NSOutlineView's row counts consistent in
sidebar-style Lists (headerless `Section { … }` overload crashed
during the AutoLayout commit).

* Rationalize delete pipeline around a typed batch aggregator

Before this commit, deleting a single item mostly worked but bulk
deletes — especially folder-row bulk unlink and section-level
"Remove Repository" on a git repo — were a patchwork of
partially-overlapping actions (`.requestDeleteWorktree`,
`.confirmDeleteSidebarItem`, `.confirmRemoveRepository`,
`.repositoryRemoved`, `folderRemovalEffect`). Each per-target
removal fired its own `.repositoryRemoved(id)` which triggered a
full async reload with `.cancellable(cancelInFlight: true)`, so
siblings cancelled each other mid-flight and only one repo
actually disappeared from the sidebar.

This commit redesigns the pipeline around four things:

1. **One closed-sum disposition**. `DeleteDisposition`
(`.gitWorktreeDelete | .gitRepositoryUnlink | .folderUnlink |
.folderTrash`) replaces the former 2×2 split between user
`DeleteAction` and recorded `RemovalIntent`, which could encode
impossible combinations like `(git worktree, .unlink)` and gave
`.delete` two different meanings depending on target kind.

2. **Batch aggregator keyed by id**.
`activeRemovalBatches: [BatchID: ActiveRemovalBatch]` lets
overlapping flows (e.g. a folder-trash still awaiting
`FileManager.trashItem` while the user confirms a git-section
remove on a different repo) each complete independently.
`removingRepositoryIDs: [Repository.ID: RepositoryRemovalRecord]`
couples the per-repo disposition with its owning batch id so
lookups and draining stay in lockstep. Bulk and single both go
through the same plural entry points (single = batch-of-1);
section-level removes seed a batch-of-1 too.

3. **One verb, one terminal**. The public surface collapses to
`.requestDeleteSidebarItems` → `.confirmDeleteSidebarItems(_,
disposition:)` → per-target `.deleteSidebarItemConfirmed` →
`.repositoryRemovalCompleted` (aggregator drain) → a single
`.repositoriesRemoved([ids])` terminal per batch. Partial
failures drain the batch without producing a terminal for the
failed targets; the aggregator clears `removingRepositoryIDs`
for failed targets so the sidebar row becomes clickable again.
`signalFolderRemovalFailure` helper collapses the three
duplicated failure tails in `.deleteScriptCompleted` (cancel /
non-zero exit / success-kind-flip) into one site.

4. **Loud invariants**. Orphan `.repositoryRemovalCompleted`
arrivals (no matching record) call `reportIssue` and
defensively clear per-worktree trackers so a future regression
can't silently leak state. Kind / disposition mismatches
(e.g. `.unlink` against a git target) also `reportIssue`
instead of dropping silently.

UX improvements:
- Single-target main-worktree delete (palette, hotkey,
context-menu) now surfaces the same "Delete not allowed" alert
the deeplink path already shows instead of silently no-opping.
Bulk selections that mix main + other worktrees keep silently
filtering so the rest of the batch proceeds.
- Folder-removal confirmation copy fixed: singular branch now reads
"…to stop managing the folder (it stays on disk)…" matching the
plural branch; previous build shipped a grammatically broken
"…to stop the folder…".

Tests (840 passing, 11 new):
- `concurrentFolderAndSectionBatchesEachCompleteIndependently`
pins the overlap invariant — folder batch in-flight + concurrent
section remove on a different repo must each produce their own
`.repositoriesRemoved`.
- `orphanCompletionReportsIssueAndFiresSoloTerminal` verifies
`reportIssue` fires and worktree trackers get defensively
cleared.
- `requestDeleteMainWorktreeShowsNotAllowedAlertForSingleTarget`
and `requestDeleteMainWorktreeInBulkRemainsSilentlyFiltered`
split the old "silently filtered" assertion into the two
behaviors we actually want.
- `bulkFolderUnlinkTerminatesWithEmptyState` (renamed from the
misnamed original — it asserts termination, not the race).
- Exhaustivity-off tests restored key
`store.receive(\.repositoriesRemoved)` /
`\.delegate.selectedWorktreeChanged` assertions so future drops
of the delegate fan-out don't pass silently.
- `State.seedRemovalBatch(pending:)` helper wires record + batch
for tests that drop straight into `.deleteSidebarItemConfirmed`.

Also refreshed `AGENTS.md` folder-pipeline notes and stale
`.repositoryRemoved` references in tests after the verb rename.

* Prune settingsFile.repositories entries when removing a repository

Removing a repo from Supacode used to leave its per-repo config
block (scripts, run command, open action) orphaned in
`settings.json` under `repositories[<path>]` — only the
`repositoryRoots` array got pruned. Users who added and removed
folders while exploring saw the dict accumulate dead entries
forever.

Add `RepositoryPersistenceClient.pruneRepositoryConfigs(_ ids:)`
that drops the given ids from `settingsFile.repositories`, and
call it from both removal paths: `.repositoriesRemoved` (normal
removal) and `.removeFailedRepository` (failed-to-load cleanup).
The two side-effects in the aggregator terminal are chained
inside the same `.run` so they share the `CancelID.persistRoots`
cancellation scope.

Regression test: `folderRemovalPrunesRootsAndConfigsFromSettings`
asserts both `saveRoots` (pruned root list) AND
`pruneRepositoryConfigs` (removed repo id) fire on folder removal.

* Harden folder + delete-pipeline edge cases

Tightens places where the new folder kind meets `isMainWorktree`
geometry or async invariant boundaries.

- Folder deeplink delete used to route into
`deeplinkDeleteWorktreeEffect`'s `isMainWorktree` gate because
folders have a synthetic main-worktree
(`workingDirectory == rootURL`). CLI users got a misleading
"main worktree not allowed" alert and couldn't remove folders
via deeplink at all. Route folder targets through
`.requestDeleteSidebarItems([target])` so the 3-button folder
alert handles the confirmation.

- `folderRemovalEffect` swallowed `FileManager.trashItem` errors
and still reported `succeeded: true`, so a failed "Delete from
disk" silently removed the folder from Supacode while leaving
the on-disk content in place. On catch, dispatch
`succeeded: false` and present a "Delete from disk failed"
alert with the error description.

- `.deleteScriptCompleted` derived `owningRepo` from
`state.repositories.first(...)`; under a reload /
`.removeFailedRepository` race the live repo could be gone and
`signalFolderRemovalFailure` would bail out, orphaning the
batch record forever. Rewrite the helper to resolve the repo id
from `state.removingRepositoryIDs` (authoritative across
reloads) using the `"folder:" + path` id convention, and drop
the `owningRepo` parameter from call sites.

- `.deleteSidebarItemConfirmed` now `reportIssue`s when a folder
target arrives without a seeded `RepositoryRemovalRecord` —
turns the accidental gate the deeplink's isMainWorktree check
used to provide into a load-bearing guard that survives the
deeplink fix above.

- Folder archive / pin / unpin hotkeys used to silently no-op
because the synthetic main-worktree satisfied `isMainWorktree`
geometrically. Surface the same "Action not available" alert
the deeplink layer already shows for these actions so hotkeys
and the deeplink match.

- Git section-removal confirmation message no longer says
"Worktrees and the main repository folder stay on disk" — the
word "folder" is now overloaded with the folder-repo concept.
Rewrote to "The repository and its worktrees stay on disk."

- Auto-delete of expired archived worktrees now `reportIssue`s
and skips folder-synthetic worktree ids (`"folder:" + path`).
Folders can't be archived by any user-reachable path today, but
an archived-entry for a folder would hit the git delete path
and fail confusingly — flag the invariant breach loudly.

Tests (845 passing, 4 new):
- `deleteFolderDeeplinkRoutesToFolderAlertPipeline`
- `requestArchiveWorktreeForFolderShowsActionNotAvailable`
(covers archive / pin / unpin)
- `folderTrashFailureSurfacesAlertAndKeepsRepo`
- `orphanCompletionSucceededFiresSoloTerminalAndRemovesRepo`
(companion to the existing succeeded=false test)

* Fix tracker leaks + repo-eviction race, coalesce bulk trash alerts

Two real bugs plus three polish items in the folder-removal
pipeline. Everything here lives in the delete flow that this
branch introduces; nothing touches repositories outside that
path.

- `.repositoryRemovalCompleted(succeeded: false)` cleared
`removingRepositoryIDs` but left the per-worktree trackers
(`deletingWorktreeIDs` / `deleteScriptWorktreeIDs`) populated.
The empty-script folder-trash branch at
RepositoriesFeature.swift:1855 seeds `deletingWorktreeIDs`
before the effect fires; on trash failure the row rendered
`.deleting(inTerminal: false)` forever. Aggregator now mirrors
the orphan-path cleanup in its hot failure branch.

- `.deleteScriptCompleted` looked up `owningRepo` via
`state.repositories.first(...)` — a concurrent reload /
`.removeFailedRepository` pruning mid-script left the batch
record orphaned and sibling folder targets hung forever. The
exit=0 no-`owningRepo` branch now probes
`removingRepositoryIDs[String(worktreeID.dropFirst("folder:".count))]`
and routes through `signalFolderRemovalFailure` when a folder
record is still there, so the batch drains even when the repo
vanished from `state.repositories`.

- `.autoDeleteExpiredArchivedWorktrees` used to `reportIssue` on
folder-prefixed archived ids and `continue` without purging,
so every `.repositoriesLoaded` re-fired the same issue forever.
Now collects stray folder-prefixed entries upfront, reports
once, and purges them via `$sidebar.withLock` so the invariant
self-heals on first encounter.

- Bulk folder-trash failures used to each dispatch
`.presentAlert` and clobber `state.alert` in a last-write-wins
race. `.repositoryRemovalCompleted` now carries an optional
`failureMessage`; the aggregator collects them in
`ActiveRemovalBatch.failureMessagesByRepositoryID` and surfaces
one consolidated alert when the batch drains — single-target
keeps the existing UX, multi-target lists every failed folder
by name so users can see which stayed on disk.

- Four inlined "Action not available" alert constructions
(RepositoriesFeature.swift archive/pin/unpin + AppFeature.swift
deeplink) collapsed into one `folderIncompatibleAlert(action:)`
helper on the reducer, plus a `FolderIncompatibleAction` enum
that drives per-action copy (`"Archive not available"` /
`"Archive only applies to git repositories."`) so the user knows
which action they just tried.

- Removal-pipeline types + helpers moved to a new
`RepositoriesFeature+Removal.swift` extension —
`DeleteDisposition`, `RepositoryRemovalRecord`,
`ActiveRemovalBatch`, `BatchID`, `FolderIncompatibleAction`,
`seedRemovalBatch`, `folderRemovalEffect`,
`signalFolderRemovalFailure`, `folderIncompatibleAlert`,
`consolidatedTrashFailureAlert`,
`confirmationAlertForRepositoryRemoval`, and `messageAlert`.
The reducer body stays in the main file (Swift reducers can't
split), but the main file drops from 4,613 to 4,488 lines and
the removal domain is now self-contained in a 293-line file.

Tests (847 passing, +2):
- `bulkFolderTrashFailuresCoalesceIntoSingleAlert` — asserts the
consolidated alert names both failed folders.
- `deleteScriptCompletedDrainsBatchWhenOwningRepoVanished` —
exercises the repo-eviction race by seeding a batch for a repo
that doesn't live in `state.repositories`.
- `folderTrashFailureSurfacesAlertAndKeepsRepo` strengthened to
assert `deletingWorktreeIDs` / `deleteScriptWorktreeIDs` clear
on failure.
- `requestArchiveWorktreeForFolderShowsActionNotAvailable`
updated to match the new per-action copy.

* Close last folder-removal gaps: alert survival, typed outcome, shared helpers

A consolidated trash-failure alert could silently vanish on the
same tick because downstream reducer actions unconditionally
cleared `state.alert`. Rooting that out turned up two
synergistic clear sites; both now leave the alert alone and let
confirmation-style alerts be cleared by their own confirm
handlers.

- Alert clobber: dropped the unconditional `state.alert = nil`
from `.deleteSidebarItemConfirmed` and `.reloadRepositories`.
`.confirmDeleteSidebarItems` already clears its own confirm
alert at entry, and `.reloadRepositories` is a data-layer
refresh that has no business wiping whatever alert a parallel
flow just set. Regression test
`deleteSidebarItemConfirmedDoesNotClobberTerminalAlert` pins
the new contract: a programmatic `.deleteSidebarItemConfirmed`
(the shape `.autoDeleteExpiredArchivedWorktrees` uses) must
leave a pre-existing terminal alert intact.

- Cross-feature alert duplication: `FolderIncompatibleAction`
grew a `.unarchive` case and the deeplink path at
`AppFeature.swift:1029-1048` now builds its alert title/body
from the shared `displayName`, so the inline switch that used
to duplicate the copy word-for-word is gone.

- Prefix coupling: `Repository.folderWorktreeIDPrefix` +
`repositoryID(fromFolderWorktreeID:)` + `isFolderWorktreeID(_:)`
on `Repository` replace four hand-parsed `"folder:"` prefix
tests scattered across the reducer and `+Removal.swift`.

- Typed outcome: `(succeeded: Bool, failureMessage: String?)` on
`.repositoryRemovalCompleted` collapsed into a single
`RemovalOutcome` sum (`.success | .failure(message: String?)`).
The action can no longer express "success + failure message"
and the aggregator reads `outcome.succeeded` /
`outcome.failureMessage` directly.

- Alert fallback + fragile lookup: `consolidatedTrashFailureAlert`
now takes a pre-resolved `namesByRepositoryID` map that the
aggregator snapshots from `state.repositories` at drain time
(before `.repositoriesRemoved` prunes anyone). Single- and
multi-target copy share one `displayName(for:)` helper that
falls back to `URL(fileURLWithPath: id).lastPathComponent`
instead of the inconsistent "the folder" / raw-path split.

- Orphan cleanup scope: the orphan-path and hot-failure cleanups
no longer iterate every worktree in `repository.worktrees` —
they clear only the folder-synthetic worktree id derived from
the repo id. The old wider sweep was safe today (only folder
dispositions reach `.failure`) but would have quietly clobbered
sibling git worktree trackers if a future caller ever fed a
git repo id through this path.

- Test hygiene: `State.seedRemovalBatch(pending:id:)` is now
`#if DEBUG`-only so production callers can't accidentally
corrupt the removal state machine.

Tests (848 passing, +1). Build green.

* Split RemovalOutcome, preserve silent alerts, tighten removal invariants

- Split RemovalOutcome.failure into .failureSilent / .failureWithMessage(String)
so the aggregator can't clobber a caller-owned alert (script failure, user
cancel, kind-flip) with the consolidated trash-failure alert. When the
batch drains with both a silent failure and trash messages, preserve the
caller's alert and log the trash errors via repositoriesLogger.warning
instead of overwriting.
- Use switch outcome at the aggregator and orphan-completion call sites
instead of if outcome.succeeded / if let outcome.failureMessage, restoring
exhaustiveness at the critical drain path.
- Drain the delete re-entry guard as a silent failure when the guard trips
for a folder that has already been seeded into the batch aggregator,
preventing the batch from hanging forever on a pending target.
- Prune removingRepositoryIDs + activeRemovalBatches in applyRepositories,
symmetric with the seven other trackers intersected against the live
roster; report the invariant break via reportIssue so tests catch future
regressions.
- Log ambiguous .git-suffixed directories (missing HEAD / objects / refs)
in Repository.isGitRepository(at:) so damaged bare clones are observable
in telemetry without widening folder classification.
- Dedup the folder-incompatible alert copy via
FolderIncompatibleAction.alertCopy so AppFeature's deeplink handler and
the reducer's folderIncompatibleAlert share one source of truth.
- Move the seedRemovalBatch test helper out of #if DEBUG in the main module
and into the test target, where @testable import supacode already gives
it access to the internal state fields.
- Add .help() tooltip to the Folder Settings context-menu button.

* Drop DA2 re-entry drain, silence reload prune, clean lint

- Revert the delete re-entry guard drain introduced in the previous
commit. The guard correctly short-circuits rapid second taps whose
first-tap `.repositoryRemovalCompleted` is already going to drain
the batch; emitting `.failureSilent` here double-drained pending
and orphaned the first completion into reportIssue, breaking the
idempotency regression test.
- Silence `prunedRemovalTrackers` in `applyRepositories`. Firing
reportIssue on every reload-during-removal flow also caught the
synchronous `.gitRepositoryUnlink` path that tests seed
deliberately, producing false failures. The symmetric prune still
keeps state consistent with the seven other trackers intersected
against the live roster; the orphan-completion branch in
`.repositoryRemovalCompleted` already handles real liveness.
- Extract the prune into `prunedRemovalTrackers` so applyRepositories
stays under the 100-line function-body limit enforced by swiftlint.
- Wrap the delete-confirmation subject line into a local binding so
it fits within the 120-character line limit.
- Delete the unused `makeFolderFixture` test helper; its tuple return
tripped the large-tuple lint rule and nothing actually called it.
- Rewrite the damaged-bare-clone HEAD write in
`damagedBareCloneClassifiesAsFolderAndWarns` to use the
non-optional `Data(_:)` initializer per the
`non_optional_string_data_conversion` lint rule.

* Show sidebar error row when a tracked folder is missing on disk

A folder-kind root that got deleted / moved / unmounted while Supacode
was running silently became an empty folder repository in the sidebar:
gitClient.isGitRepository returned false for the missing path (the
FileManager checks inside it all miss), loadRepositoriesData took the
non-git branch, and synthesized a folder row with no indication that
the directory was gone. Git repos already surface this via
loadFailuresByID → SidebarFailedRepositoryRow; folders did not.

Add a `rootDirectoryExists` closure to GitClientDependency so the
loader can distinguish "directory is gone" (failure row) from
"directory exists but isn't a git repo" (folder row). The live
implementation uses FileManager.fileExists on the standardized path;
testValue defaults to `true` so fixtures with fake /tmp paths keep
exercising the classification branches they were written for.

Route the missing-directory case through the same LoadFailure
pipeline git failures use, so the sidebar renders the familiar error
row with a "this directory may have been moved or deleted" message.

Covered by a new regression test that stubs rootDirectoryExists to
return false and asserts the loader emits a loadFailuresByID entry
instead of a synthesized folder repository.

authored by

Stefano Bertagno and committed by
GitHub
68e44966 7981cf34

+3838 -462
+11 -1
AGENTS.md
··· 31 31 32 32 ``` 33 33 AppFeature (root TCA store) 34 - ├─ RepositoriesFeature (repos, worktrees, PR state, archive/delete flows) 34 + ├─ RepositoriesFeature (repos + folders, worktrees, PR state, archive/delete flows) 35 35 ├─ CommandPaletteFeature 36 36 ├─ SettingsFeature (general, notifications, coding agents, shortcuts, github, worktree, repo settings) 37 37 └─ UpdatesFeature (Sparkle auto-updates) ··· 128 128 - Automatically commit your changes and your changes only. Do not use `git add .` 129 129 - Before you go on your task, check the current git branch name, if it's something generic like an animal name, name it accordingly. Do not do this for main branch 130 130 - After implementing an execplan, always submit a PR if you're not in the main branch 131 + 132 + ## Folder (non-git) repositories 133 + 134 + - `Repository.isGitRepository` classifies each root at load time via `Repository.isGitRepository(at:)` (checks `.git` dir/file and the `.bare` / `.git` root-name conventions). Classification runs through the injected `GitClientDependency.isGitRepository` closure so tests can override it without touching the filesystem. 135 + - 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. 136 + - 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). 137 + - 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. 138 + - 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. 139 + - `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. 140 + - 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. 131 141 132 142 ## Submodules 133 143
+3 -1
SupacodeSettingsFeature/Models/SettingsRepositorySummary.swift
··· 3 3 public struct SettingsRepositorySummary: Equatable, Hashable, Sendable { 4 4 public var id: String 5 5 public var name: String 6 + public var isGitRepository: Bool 6 7 7 8 public var rootURL: URL { 8 9 URL(fileURLWithPath: id).standardizedFileURL 9 10 } 10 11 11 - public init(id: String, name: String) { 12 + public init(id: String, name: String, isGitRepository: Bool = true) { 12 13 self.id = id 13 14 self.name = name 15 + self.isGitRepository = isGitRepository 14 16 } 15 17 }
+22
SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift
··· 8 8 @ObservableState 9 9 public struct State: Equatable { 10 10 public var rootURL: URL 11 + public var isGitRepository: Bool 11 12 public var settings: RepositorySettings 12 13 public var globalDefaultWorktreeBaseDirectoryPath: String? 13 14 public var globalCopyIgnoredOnWorktreeCreate: Bool = false ··· 31 32 32 33 public init( 33 34 rootURL: URL, 35 + isGitRepository: Bool = true, 34 36 settings: RepositorySettings, 35 37 globalDefaultWorktreeBaseDirectoryPath: String? = nil, 36 38 globalCopyIgnoredOnWorktreeCreate: Bool = false, ··· 42 44 isBranchDataLoaded: Bool = false 43 45 ) { 44 46 self.rootURL = rootURL 47 + self.isGitRepository = isGitRepository 45 48 self.settings = settings 46 49 self.globalDefaultWorktreeBaseDirectoryPath = globalDefaultWorktreeBaseDirectoryPath 47 50 self.globalCopyIgnoredOnWorktreeCreate = globalCopyIgnoredOnWorktreeCreate ··· 92 95 switch action { 93 96 case .task: 94 97 let rootURL = state.rootURL 98 + let isGitRepository = state.isGitRepository 95 99 @Shared(.repositorySettings(rootURL)) var repositorySettings 96 100 @Shared(.settingsFile) var settingsFile 97 101 let settings = repositorySettings ··· 102 106 let globalMergeStrategy = global.pullRequestMergeStrategy 103 107 let gitClient = gitClient 104 108 return .run { send in 109 + // Folders don't expose the general settings page, so skip 110 + // the git-only queries (`isBareRepository`, `branchRefs`, 111 + // `automaticWorktreeBaseRef`) that would otherwise log 112 + // subprocess warnings against a non-git directory. 113 + guard isGitRepository else { 114 + await send( 115 + .settingsLoaded( 116 + settings, 117 + isBareRepository: false, 118 + globalDefaultWorktreeBaseDirectoryPath: globalDefaultWorktreeBaseDirectoryPath, 119 + globalCopyIgnoredOnWorktreeCreate: globalCopyIgnored, 120 + globalCopyUntrackedOnWorktreeCreate: globalCopyUntracked, 121 + globalPullRequestMergeStrategy: globalMergeStrategy 122 + ) 123 + ) 124 + await send(.branchDataLoaded([], defaultBaseRef: "HEAD")) 125 + return 126 + } 105 127 let isBareRepository = (try? await gitClient.isBareRepository(rootURL)) ?? false 106 128 await send( 107 129 .settingsLoaded(
+6
SupacodeSettingsFeature/Reducer/SettingsFeature.swift
··· 563 563 @Shared(.repositorySettings(summary.rootURL)) var repositorySettings 564 564 state.repositorySettings = RepositorySettingsFeature.State( 565 565 rootURL: summary.rootURL, 566 + isGitRepository: summary.isGitRepository, 566 567 settings: repositorySettings 567 568 ) 569 + } else { 570 + // Summary can flip kind at runtime (git → folder or vice versa) 571 + // without the selection changing — keep the feature state in 572 + // sync so the scripts page picks the right render path. 573 + state.repositorySettings?.isGitRepository = summary.isGitRepository 568 574 } 569 575 state.syncGlobalDefaults(from: state.globalSettings) 570 576 }
+26 -18
SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift
··· 12 12 13 13 public var body: some View { 14 14 Form { 15 - // Lifecycle scripts. 16 - LifecycleScriptSection( 17 - text: $store.settings.setupScript, 18 - title: "Setup Script", 19 - subtitle: "Runs once after worktree creation.", 20 - icon: "truck.box.badge.clock", 21 - iconColor: .blue, 22 - footerExample: "pnpm install" 23 - ) 24 - LifecycleScriptSection( 25 - text: $store.settings.archiveScript, 26 - title: "Archive Script", 27 - subtitle: "Runs before a worktree is archived.", 28 - icon: "archivebox", 29 - iconColor: .orange, 30 - footerExample: "docker compose down" 31 - ) 15 + // Setup + Archive scripts are git-only — worktree creation 16 + // and worktree archival are the triggers and folders have 17 + // neither. The Delete script stays: it runs before the folder 18 + // itself is removed from Supacode through the blocking-script 19 + // pipeline. 20 + if store.isGitRepository { 21 + LifecycleScriptSection( 22 + text: $store.settings.setupScript, 23 + title: "Setup Script", 24 + subtitle: "Runs once after worktree creation.", 25 + icon: "truck.box.badge.clock", 26 + iconColor: .blue, 27 + footerExample: "pnpm install" 28 + ) 29 + LifecycleScriptSection( 30 + text: $store.settings.archiveScript, 31 + title: "Archive Script", 32 + subtitle: "Runs before a worktree is archived.", 33 + icon: "archivebox", 34 + iconColor: .orange, 35 + footerExample: "docker compose down" 36 + ) 37 + } 32 38 LifecycleScriptSection( 33 39 text: $store.settings.deleteScript, 34 40 title: "Delete Script", 35 - subtitle: "Runs before a worktree is deleted.", 41 + subtitle: store.isGitRepository 42 + ? "Runs before a worktree is deleted." 43 + : "Runs before this folder is removed from Supacode.", 36 44 icon: "trash", 37 45 iconColor: .red, 38 46 footerExample: "docker compose down"
+1 -1
SupacodeSettingsShared/App/AppShortcuts.swift
··· 111 111 case .selectWorktree(let index): "Select Worktree \(index == 0 ? 10 : index)" 112 112 case .openWorktree: "Open Worktree" 113 113 case .revealInFinder: "Reveal in Finder" 114 - case .openRepository: "Open Repository" 114 + case .openRepository: "Open Repository or Folder" 115 115 case .openPullRequest: "Open Pull Request" 116 116 case .copyPath: "Copy Path" 117 117 case .runScript: "Run Script"
+29 -1
supacode/Clients/Repositories/GitClientDependency.swift
··· 3 3 4 4 struct GitClientDependency: Sendable { 5 5 var repoRoot: @Sendable (URL) async throws -> URL 6 + var isGitRepository: @Sendable (URL) async -> Bool 7 + /// Whether a root URL still points at a readable directory on 8 + /// disk. Separate from `isGitRepository` because a folder-kind 9 + /// root can exist without being a git repository, and we need 10 + /// to distinguish "directory is gone" (surface a load failure) 11 + /// from "directory exists but isn't git" (classify as folder). 12 + /// Defaults to `true` in `testValue` so fixtures with fake 13 + /// `/tmp/...` paths keep working; tests that exercise the 14 + /// missing-directory path override explicitly. 15 + var rootDirectoryExists: @Sendable (URL) async -> Bool 6 16 var worktrees: @Sendable (URL) async throws -> [Worktree] 7 17 var pruneWorktrees: @Sendable (URL) async throws -> Void 8 18 var localBranchNames: @Sendable (URL) async throws -> Set<String> ··· 44 54 extension GitClientDependency: DependencyKey { 45 55 static let liveValue = GitClientDependency( 46 56 repoRoot: { try await GitClient().repoRoot(for: $0) }, 57 + isGitRepository: { Repository.isGitRepository(at: $0) }, 58 + rootDirectoryExists: { url in 59 + var isDirectory: ObjCBool = false 60 + let exists = FileManager.default.fileExists( 61 + atPath: url.standardizedFileURL.path(percentEncoded: false), 62 + isDirectory: &isDirectory 63 + ) 64 + return exists && isDirectory.boolValue 65 + }, 47 66 worktrees: { try await GitClient().worktrees(for: $0) }, 48 67 pruneWorktrees: { try await GitClient().pruneWorktrees(for: $0) }, 49 68 localBranchNames: { try await GitClient().localBranchNames(for: $0) }, ··· 90 109 await GitClient().remoteInfo(for: repositoryRoot) 91 110 } 92 111 ) 93 - static let testValue = liveValue 112 + // Tests default to "git repository" classification so existing 113 + // fixtures that mock `gitClient.worktrees` without creating real 114 + // `.git` directories on disk keep exercising the git code path. 115 + // Folder-kind tests override this closure explicitly. 116 + static var testValue: GitClientDependency { 117 + var value = liveValue 118 + value.isGitRepository = { _ in true } 119 + value.rootDirectoryExists = { _ in true } 120 + return value 121 + } 94 122 } 95 123 96 124 extension DependencyValues {
+20 -1
supacode/Clients/Repositories/RepositoryPersistenceClient.swift
··· 11 11 struct RepositoryPersistenceClient { 12 12 var loadRoots: @Sendable () async -> [String] 13 13 var saveRoots: @Sendable ([String]) async -> Void 14 + /// Remove the per-repository entries (`settingsFile.repositories` 15 + /// dict — scripts, run config, open action, _etc._) for repos that 16 + /// have been removed from Supacode so dead entries don't 17 + /// accumulate in `settings.json`. Pair with `saveRoots` at repo 18 + /// removal time; the two operate on different slices of the same 19 + /// settings file but share no enforced ordering. 20 + var pruneRepositoryConfigs: @Sendable ([String]) async -> Void 14 21 } 15 22 16 23 extension RepositoryPersistenceClient: DependencyKey { ··· 25 32 $sharedRoots.withLock { 26 33 $0 = roots 27 34 } 35 + }, 36 + pruneRepositoryConfigs: { repositoryIDs in 37 + guard !repositoryIDs.isEmpty else { return } 38 + let ids = Set(repositoryIDs.compactMap(RepositoryPathNormalizer.normalize)) 39 + guard !ids.isEmpty else { return } 40 + @Shared(.settingsFile) var settingsFile: SettingsFile 41 + $settingsFile.withLock { settings in 42 + for id in ids { 43 + settings.repositories.removeValue(forKey: id) 44 + } 45 + } 28 46 } 29 47 ) 30 48 }() 31 49 static let testValue = RepositoryPersistenceClient( 32 50 loadRoots: { [] }, 33 - saveRoots: { _ in } 51 + saveRoots: { _ in }, 52 + pruneRepositoryConfigs: { _ in } 34 53 ) 35 54 } 36 55
+7 -7
supacode/Commands/WorktreeCommands.swift
··· 24 24 var body: some Commands { 25 25 let overrides = store.settings.shortcutOverrides 26 26 let repositories = store.repositories 27 - let orderedRows = visibleHotkeyWorktreeRows ?? repositories.orderedWorktreeRows() 27 + let orderedRows = visibleHotkeyWorktreeRows ?? repositories.orderedSidebarItems() 28 28 let pullRequestURL = selectedPullRequestURL 29 29 let githubIntegrationEnabled = store.settings.githubIntegrationEnabled 30 30 let selectNext = AppShortcuts.selectNextWorktree.effective(from: overrides) ··· 141 141 } 142 142 } 143 143 CommandGroup(replacing: .newItem) { 144 - Button("Add Repository...", systemImage: "folder.badge.plus") { 144 + Button("Add Repository or Folder...", systemImage: "folder.badge.plus") { 145 145 store.send(.repositories(.setOpenPanelPresented(true))) 146 146 } 147 147 .appKeyboardShortcut(openRepo) 148 - .help("Add Repository (\(openRepo?.display ?? "none"))") 148 + .help("Add Repository or Folder (\(openRepo?.display ?? "none"))") 149 149 Button("Confirm Action") { 150 150 confirmWorktreeAction?() 151 151 } ··· 171 171 private struct WorktreeShortcutButton: View { 172 172 let index: Int 173 173 let shortcut: AppShortcut? 174 - let orderedRows: [WorktreeRowModel] 174 + let orderedRows: [SidebarItemModel] 175 175 let store: StoreOf<AppFeature> 176 176 177 - private var row: WorktreeRowModel? { 177 + private var row: SidebarItemModel? { 178 178 orderedRows.indices.contains(index) ? orderedRows[index] : nil 179 179 } 180 180 ··· 260 260 set { self[StopRunScriptActionKey.self] = newValue } 261 261 } 262 262 263 - var visibleHotkeyWorktreeRows: [WorktreeRowModel]? { 263 + var visibleHotkeyWorktreeRows: [SidebarItemModel]? { 264 264 get { self[VisibleHotkeyWorktreeRowsKey.self] } 265 265 set { self[VisibleHotkeyWorktreeRowsKey.self] = newValue } 266 266 } ··· 275 275 } 276 276 277 277 private struct VisibleHotkeyWorktreeRowsKey: FocusedValueKey { 278 - typealias Value = [WorktreeRowModel] 278 + typealias Value = [SidebarItemModel] 279 279 }
+106
supacode/Domain/Repository.swift
··· 1 1 import Foundation 2 2 import IdentifiedCollections 3 + import SupacodeSettingsShared 4 + 5 + private nonisolated let repositoryClassificationLogger = SupaLogger("RepositoryClassification") 3 6 4 7 struct Repository: Identifiable, Hashable, Sendable { 5 8 let id: String 6 9 let rootURL: URL 7 10 let name: String 8 11 let worktrees: IdentifiedArrayOf<Worktree> 12 + // Runtime classification — `false` means the rootURL is a plain 13 + // directory (no `.git` / `.bare`) and the repository is treated as 14 + // a non-git folder. Persistence is unchanged; this flips freely on 15 + // reload when the directory is (un)initialized as a git repo. 16 + let isGitRepository: Bool 17 + 18 + init( 19 + id: String, 20 + rootURL: URL, 21 + name: String, 22 + worktrees: IdentifiedArrayOf<Worktree>, 23 + isGitRepository: Bool = true 24 + ) { 25 + self.id = id 26 + self.rootURL = rootURL 27 + self.name = name 28 + self.worktrees = worktrees 29 + self.isGitRepository = isGitRepository 30 + } 9 31 10 32 var initials: String { 11 33 Self.initials(from: name) 34 + } 35 + 36 + /// Synchronous check for whether a root URL is a git repository. 37 + /// Covers the two layouts git actually produces: 38 + /// 1. `rootURL` IS the metadata dir — `.git/` for a normal repo, 39 + /// `.bare` for the naming convention Supacode's `name(for:)` 40 + /// helper already recognizes. Lastpathcomponent check. 41 + /// 2. Standard repo: `rootURL/.git` exists — a directory for 42 + /// primary repos, a worktree-pointer file for linked 43 + /// worktrees (also the git-wt bare wrapper, where `.git` is 44 + /// a pointer file to the sibling bare dir). 45 + /// Pure FileManager call — safe to invoke off the main actor from 46 + /// the `GitClientDependency` closure. 47 + nonisolated static func isGitRepository(at rootURL: URL) -> Bool { 48 + let fileManager = FileManager.default 49 + let lastComponent = rootURL.lastPathComponent 50 + if lastComponent == ".bare" || lastComponent == ".git" { 51 + return true 52 + } 53 + let dotGitPath = 54 + rootURL 55 + .appending(path: ".git", directoryHint: .notDirectory) 56 + .path(percentEncoded: false) 57 + if fileManager.fileExists(atPath: dotGitPath) { 58 + return true 59 + } 60 + // Bare-clone convention: `<name>.git/` with HEAD + objects/ + refs/ 61 + // at the root (what `git clone --bare` produces). The name-only 62 + // `.bare` / `.git` shortcuts above cover Supacode's own layouts; 63 + // this catches user-managed bare clones imported via the Open panel. 64 + guard lastComponent.hasSuffix(".git") else { return false } 65 + let head = rootURL.appending(path: "HEAD", directoryHint: .notDirectory).path(percentEncoded: false) 66 + let objects = rootURL.appending(path: "objects", directoryHint: .isDirectory).path(percentEncoded: false) 67 + let refs = rootURL.appending(path: "refs", directoryHint: .isDirectory).path(percentEncoded: false) 68 + let hasHead = fileManager.fileExists(atPath: head) 69 + let hasObjects = fileManager.fileExists(atPath: objects) 70 + let hasRefs = fileManager.fileExists(atPath: refs) 71 + if hasHead && hasObjects && hasRefs { 72 + return true 73 + } 74 + // `.git`-suffixed directory missing one of the three structural 75 + // parts of a bare clone — log so the ambiguous "looks like a 76 + // damaged bare clone but classifies as a folder" case is 77 + // observable in telemetry without widening classification and 78 + // creating false positives for empty `.git` directories. 79 + repositoryClassificationLogger.warning( 80 + "Directory ending in .git missing bare-clone structure — " 81 + + "classified as folder. path=\(rootURL.path(percentEncoded: false)) " 82 + + "hasHead=\(hasHead) hasObjects=\(hasObjects) hasRefs=\(hasRefs)" 83 + ) 84 + return false 85 + } 86 + 87 + /// Prefix on folder-synthetic worktree ids. Single source of truth 88 + /// so reducer call sites that need to recover the repo id from a 89 + /// folder worktree id (see `repositoryID(fromFolderWorktreeID:)`) 90 + /// stay in sync with the constructor below. 91 + nonisolated static let folderWorktreeIDPrefix = "folder:" 92 + 93 + /// Stable synthetic worktree id for folder repositories. Keeps the 94 + /// existing `SidebarSelection.worktree(id)` + terminal-manager 95 + /// plumbing unchanged — folders reuse the same selection path. 96 + nonisolated static func folderWorktreeID(for rootURL: URL) -> Worktree.ID { 97 + folderWorktreeIDPrefix + rootURL.standardizedFileURL.path(percentEncoded: false) 98 + } 99 + 100 + /// Round-trip for `folderWorktreeID(for:)`: recover the owning 101 + /// `Repository.ID` (the standardized path) from a folder-synthetic 102 + /// worktree id. Returns `nil` for non-folder ids so callers can 103 + /// distinguish "this isn't a folder worktree" from "this is a 104 + /// folder worktree without a known repo." 105 + nonisolated static func repositoryID( 106 + fromFolderWorktreeID worktreeID: Worktree.ID 107 + ) -> Repository.ID? { 108 + guard worktreeID.hasPrefix(folderWorktreeIDPrefix) else { return nil } 109 + return String(worktreeID.dropFirst(folderWorktreeIDPrefix.count)) 110 + } 111 + 112 + /// Whether `worktreeID` is a folder-synthetic worktree id (as 113 + /// produced by `folderWorktreeID(for:)`). Cheaper than calling 114 + /// `repositoryID(fromFolderWorktreeID:)` when the caller only 115 + /// wants the discrimination. 116 + nonisolated static func isFolderWorktreeID(_ worktreeID: Worktree.ID) -> Bool { 117 + worktreeID.hasPrefix(folderWorktreeIDPrefix) 12 118 } 13 119 14 120 static func name(for rootURL: URL) -> String {
+8 -1
supacode/Domain/WorktreeRowModel.swift supacode/Domain/SidebarItemModel.swift
··· 1 1 import Foundation 2 2 3 - struct WorktreeRowModel: Identifiable, Hashable { 3 + struct SidebarItemModel: Identifiable, Hashable { 4 + enum Kind: Hashable { 5 + case git 6 + case folder 7 + } 8 + 4 9 enum Status: Hashable { 5 10 case idle 6 11 case pending ··· 10 15 11 16 let id: String 12 17 let repositoryID: Repository.ID 18 + let kind: Kind 13 19 let name: String 14 20 let detail: String 15 21 let info: WorktreeInfoEntry? ··· 17 23 let isMainWorktree: Bool 18 24 let status: Status 19 25 26 + var isFolder: Bool { kind == .folder } 20 27 var isPending: Bool { status == .pending } 21 28 var isArchiving: Bool { status == .archiving } 22 29 var isDeleting: Bool { if case .deleting = status { true } else { false } }
+107 -9
supacode/Features/App/Reducer/AppFeature.swift
··· 220 220 .send( 221 221 .settings( 222 222 .repositoriesChanged( 223 - repositories.map { SettingsRepositorySummary(id: $0.id, name: $0.name) } 223 + repositories.map { 224 + SettingsRepositorySummary( 225 + id: $0.id, 226 + name: $0.name, 227 + isGitRepository: $0.isGitRepository 228 + ) 229 + } 224 230 ) 225 231 ) 226 232 ), ··· 249 255 return openWorktreeEffect(worktree: worktree, action: action, source: .contextMenu, state: state) 250 256 251 257 case .repositories(.delegate(.openRepositorySettings(let repositoryID))): 252 - guard state.repositories.repositories.contains(where: { $0.id == repositoryID }) else { 258 + guard let repository = state.repositories.repositories[id: repositoryID] else { 253 259 return .none 254 260 } 255 - return .send(.settings(.setSelection(.repository(repositoryID)))) 261 + // Folders don't expose the general `.repository` page (no 262 + // branches, worktree config, etc.) — route them straight to 263 + // the scripts page which is the only settings surface that 264 + // applies to them. 265 + let section: SettingsSection = 266 + repository.isGitRepository ? .repository(repositoryID) : .repositoryScripts(repositoryID) 267 + return .send(.settings(.setSelection(section))) 256 268 257 269 case .repositories(.delegate(.runBlockingScript(let worktree, _, let kind, let script))): 258 270 return .run { _ in ··· 701 713 return .send(.repositories(.setOpenPanelPresented(true))) 702 714 703 715 case .commandPalette(.delegate(.removeWorktree(let worktreeID, let repositoryID))): 704 - return .send(.repositories(.requestDeleteWorktree(worktreeID, repositoryID))) 716 + return .send( 717 + .repositories( 718 + .requestDeleteSidebarItems([ 719 + RepositoriesFeature.DeleteWorktreeTarget( 720 + worktreeID: worktreeID, repositoryID: repositoryID) 721 + ]))) 705 722 706 723 case .commandPalette(.delegate(.archiveWorktree(let worktreeID, let repositoryID))): 707 724 return .send(.repositories(.requestArchiveWorktree(worktreeID, repositoryID))) ··· 916 933 case .repoOpen(let path): 917 934 return .send(.repositories(.openRepositories([path]))) 918 935 case .repoWorktreeNew(let repositoryID, let branch, let baseRef, let fetchOrigin): 919 - guard state.repositories.repositories[id: repositoryID] != nil else { 936 + guard let repository = state.repositories.repositories[id: repositoryID] else { 920 937 deeplinkLogger.warning("Repository not found: \(repositoryID)") 921 938 state.alert = AlertState { 922 939 TextState("Repository not found") ··· 929 946 } 930 947 return .none 931 948 } 949 + // Worktree creation is git-only. Reject the deeplink with a 950 + // clear alert when it targets a folder rather than letting the 951 + // request fall into `createWorktreeStream`. 952 + guard repository.isGitRepository else { 953 + deeplinkLogger.warning( 954 + "Ignoring repoWorktreeNew deeplink for folder repository: \(repositoryID)" 955 + ) 956 + state.alert = AlertState { 957 + TextState("Worktrees not available") 958 + } actions: { 959 + ButtonState(role: .cancel, action: .dismiss) { 960 + TextState("OK") 961 + } 962 + } message: { 963 + TextState("Worktrees are only supported for git repositories.") 964 + } 965 + return .none 966 + } 932 967 guard let branch else { 933 968 return .send(.repositories(.createRandomWorktreeInRepository(repositoryID))) 934 969 } ··· 945 980 case .settings(let section): 946 981 return handleSettingsDeeplink(section: section) 947 982 case .settingsRepo(let repositoryID): 948 - guard state.repositories.repositories[id: repositoryID] != nil else { 983 + guard let repository = state.repositories.repositories[id: repositoryID] else { 949 984 deeplinkLogger.warning("Repository not found for settings deeplink: \(repositoryID)") 950 985 state.alert = AlertState { 951 986 TextState("Repository not found") ··· 958 993 } 959 994 return .none 960 995 } 961 - return .send(.settings(.setSelection(.repository(repositoryID)))) 996 + // Folders have no general settings pane — send them to the 997 + // scripts page (the only settings surface that applies). 998 + let section: SettingsSection = 999 + repository.isGitRepository ? .repository(repositoryID) : .repositoryScripts(repositoryID) 1000 + return .send(.settings(.setSelection(section))) 962 1001 } 963 1002 } 964 1003 ··· 978 1017 state.alert = worktreeNotFoundAlert() 979 1018 return .none 980 1019 } 1020 + // Folders expose the worktree deeplink surface only for the 1021 + // actions that actually apply — select, open terminals, delete, 1022 + // run scripts. `.archive` / `.unarchive` / `.pin` / `.unpin` 1023 + // make no sense for a folder's synthetic main worktree, so 1024 + // reject them explicitly rather than silently no-op-ing. 1025 + if let folderRepoID = state.repositories.repositoryID(for: worktreeID), 1026 + let folderRepo = state.repositories.repositories[id: folderRepoID], 1027 + !folderRepo.isGitRepository 1028 + { 1029 + let incompatibleAction: RepositoriesFeature.FolderIncompatibleAction? 1030 + switch action { 1031 + case .archive: incompatibleAction = .archive 1032 + case .unarchive: incompatibleAction = .unarchive 1033 + case .pin: incompatibleAction = .pin 1034 + case .unpin: incompatibleAction = .unpin 1035 + default: incompatibleAction = nil 1036 + } 1037 + if let incompatibleAction { 1038 + // Copy shared with the in-reducer folder hotkey handlers 1039 + // via `FolderIncompatibleAction.alertCopy`. The 1040 + // `AlertState<_>` type diverges (this feature's `Alert` 1041 + // has its own action surface) so the struct itself can't 1042 + // be shared, but the title / message strings live in one 1043 + // place and can't drift between entry points. 1044 + let copy = incompatibleAction.alertCopy 1045 + state.alert = AlertState { 1046 + TextState(copy.title) 1047 + } actions: { 1048 + ButtonState(role: .cancel, action: .dismiss) { 1049 + TextState("OK") 1050 + } 1051 + } message: { 1052 + TextState(copy.message) 1053 + } 1054 + return .none 1055 + } 1056 + } 981 1057 982 1058 let policyBypass = state.settings.automatedActionPolicy.allowsBypass(from: source) 983 1059 let selectEffect: Effect<Action> = ··· 1262 1338 guard let repositoryID = resolveRepositoryID(for: worktreeID, label: "delete", state: &state) else { 1263 1339 return .none 1264 1340 } 1341 + // Folder repos have a synthesized main-worktree whose 1342 + // `workingDirectory == rootURL`, so `isMainWorktree(worktree)` 1343 + // is true by geometry — rejecting them here would show a 1344 + // misleading "main worktree" alert and prevent folders from 1345 + // ever being removed via deeplink. Route folder targets to 1346 + // `.requestDeleteSidebarItems([target])` so the 3-button folder 1347 + // alert pipeline (Remove / Delete / Cancel) handles the 1348 + // confirmation and the batch aggregator drains normally. 1349 + let repository = state.repositories.repositories[id: repositoryID] 1350 + let isFolder = repository?.isGitRepository == false 1265 1351 if let worktree = state.repositories.worktree(for: worktreeID), 1266 - state.repositories.isMainWorktree(worktree) 1352 + state.repositories.isMainWorktree(worktree), 1353 + !isFolder 1267 1354 { 1268 1355 state.alert = AlertState { 1269 1356 TextState("Delete not allowed") ··· 1276 1363 } 1277 1364 return .none 1278 1365 } 1366 + let target = RepositoriesFeature.DeleteWorktreeTarget( 1367 + worktreeID: worktreeID, repositoryID: repositoryID 1368 + ) 1369 + if isFolder { 1370 + // Folders always surface the 3-button confirmation so users 1371 + // can pick between `.folderUnlink` (drop from sidebar, stay 1372 + // on disk) and `.folderTrash` (move to Trash). The deeplink 1373 + // `bypassConfirmation` flag still shows it — there's no 1374 + // reasonable default disposition for folders. 1375 + return .send(.repositories(.requestDeleteSidebarItems([target]))) 1376 + } 1279 1377 let worktreeName = state.repositories.worktree(for: worktreeID)?.name ?? worktreeID 1280 1378 guard bypassConfirmation else { 1281 1379 return presentDeeplinkConfirmation( ··· 1286 1384 state: &state 1287 1385 ) 1288 1386 } 1289 - return .send(.repositories(.deleteWorktreeConfirmed(worktreeID, repositoryID))) 1387 + return .send(.repositories(.deleteSidebarItemConfirmed(worktreeID, repositoryID))) 1290 1388 } 1291 1389 1292 1390 private func resolveRepositoryID(
+7 -3
supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift
··· 185 185 ), 186 186 CommandPaletteItem( 187 187 id: CommandPaletteItemID.globalOpenRepository, 188 - title: "Open Repository", 188 + title: "Open Repository or Folder", 189 189 subtitle: nil, 190 190 kind: .openRepository 191 191 ), ··· 228 228 #if DEBUG 229 229 items.append(contentsOf: debugToastItems()) 230 230 #endif 231 - for row in repositories.orderedWorktreeRows() { 231 + for row in repositories.orderedSidebarItems() { 232 232 guard row.status == .idle else { continue } 233 233 let repositoryName = repositories.repositoryName(for: row.repositoryID) ?? "Repository" 234 - let title = "\(repositoryName) / \(row.name)" 234 + // Folder rows only have a synthetic "main" worktree whose name 235 + // matches the repository, so the usual `repo / worktree` 236 + // format would render as `Foo / Foo`. Use the repository name 237 + // alone for folders. 238 + let title = row.isFolder ? repositoryName : "\(repositoryName) / \(row.name)" 235 239 items.append( 236 240 CommandPaletteItem( 237 241 id: CommandPaletteItemID.worktreeSelect(row.id),
+1 -1
supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift
··· 504 504 case .checkForUpdates: 505 505 base = "Check for Updates" 506 506 case .openRepository: 507 - base = "Open Repository" 507 + base = "Open Repository or Folder" 508 508 case .openSettings: 509 509 base = "Open Settings" 510 510 case .newWorktree:
+331
supacode/Features/Repositories/Reducer/RepositoriesFeature+Removal.swift
··· 1 + import ComposableArchitecture 2 + import Foundation 3 + import IdentifiedCollections 4 + import SupacodeSettingsShared 5 + 6 + /// Extracted removal pipeline — the types that model the 7 + /// request/confirm/drain/terminal flow for folder + git-section 8 + /// removals, plus the helper functions the reducer body calls on 9 + /// that flow. Pure move from `RepositoriesFeature.swift`; the 10 + /// reducer body (which must live in one place because Swift 11 + /// reducers don't support split bodies) still drives this from the 12 + /// main file. 13 + /// 14 + /// Keeps `RepositoriesFeature.swift` below the scroll-threshold 15 + /// where "find the handler for action X" becomes a file-wide search. 16 + 17 + extension RepositoriesFeature { 18 + /// What actually happens on disk when a delete request resolves. 19 + /// One closed sum for the four real outcomes — this replaces the 20 + /// former 2×2 split between the user-facing `DeleteAction` choice 21 + /// and the recorded `RemovalIntent`, which could encode 22 + /// impossible combinations like `(git worktree, .unlink)` and 23 + /// gave `.delete` two different meanings depending on target 24 + /// kind. 25 + /// 26 + /// `.gitWorktreeDelete` removes the worktree directory (and 27 + /// optionally its branch) and is per-worktree; the other three 28 + /// are repo-level and drop the whole section from Supacode, with 29 + /// `.folderTrash` additionally moving the folder to the Trash. 30 + enum DeleteDisposition: Equatable, Sendable { 31 + case gitWorktreeDelete 32 + case gitRepositoryUnlink 33 + case folderUnlink 34 + case folderTrash 35 + 36 + /// Whether the disposition targets a folder repository. Used 37 + /// by the delete-script pipeline to decide whether a 38 + /// completion should drain the repo-level batch aggregator. 39 + var isFolder: Bool { 40 + switch self { 41 + case .gitWorktreeDelete, .gitRepositoryUnlink: false 42 + case .folderUnlink, .folderTrash: true 43 + } 44 + } 45 + 46 + /// Whether the disposition removes an entire repo from state 47 + /// (true for every case except the per-worktree git delete). 48 + /// Only repo-level dispositions are ever stored in 49 + /// `removingRepositoryIDs`. 50 + var isRepositoryLevel: Bool { 51 + switch self { 52 + case .gitWorktreeDelete: false 53 + case .gitRepositoryUnlink, .folderUnlink, .folderTrash: true 54 + } 55 + } 56 + } 57 + 58 + /// Opaque identifier for a batch of repo-level removals. Minted 59 + /// by the confirm handler so each concurrent flow owns its own 60 + /// aggregator and they can't clobber one another. 61 + typealias BatchID = UUID 62 + 63 + /// Per-repo bookkeeping for an in-flight repo-level removal. 64 + /// Couples the user-confirmed disposition with the owning batch 65 + /// id so the aggregator can drain the right batch when each 66 + /// target's `.repositoryRemovalCompleted` lands. 67 + struct RepositoryRemovalRecord: Equatable, Sendable { 68 + let disposition: DeleteDisposition 69 + let batchID: BatchID 70 + } 71 + 72 + /// Accumulates per-target completion signals for a bulk 73 + /// repo-level deletion so the reducer can fire a single terminal 74 + /// `.repositoriesRemoved([ids], ...)` after the whole batch 75 + /// drains. `pending` starts populated with every target the 76 + /// confirm handler accepted; each `.repositoryRemovalCompleted` 77 + /// removes its ID and (on `.success`) appends to `succeeded`. 78 + /// `selectionWasRemoved` is OR'ed across targets so the sidebar 79 + /// selection resets exactly once at the terminal. 80 + struct ActiveRemovalBatch: Equatable, Sendable { 81 + let id: BatchID 82 + var pending: Set<Repository.ID> 83 + var succeeded: [Repository.ID] = [] 84 + var selectionWasRemoved: Bool = false 85 + /// Per-target failure messages collected as completions drain. 86 + /// Surfaced in a single consolidated alert when the batch 87 + /// finishes so bulk trash failures don't clobber each other 88 + /// via per-target `.presentAlert` races. 89 + var failureMessagesByRepositoryID: [Repository.ID: String] = [:] 90 + /// Set to `true` when any target in the batch reported a 91 + /// `.failureSilent` outcome — i.e. a failure whose caller 92 + /// already set `state.alert` directly (blocking-script 93 + /// failure, user cancel, kind-flip). At drain time the 94 + /// aggregator uses this to avoid overwriting the caller's 95 + /// alert with the consolidated trash alert; any accumulated 96 + /// trash-failure messages are logged instead so they stay 97 + /// visible in telemetry without clobbering the UI. 98 + var hasSilentFailure: Bool = false 99 + } 100 + 101 + /// What a per-target `.repositoryRemovalCompleted` signal reports 102 + /// back to the batch aggregator. Split into three cases so the 103 + /// type system distinguishes "caller owns the alert" (silent 104 + /// failure) from "aggregator owns the alert" (failure with a 105 + /// user-facing message) — previously folded into a single 106 + /// `.failure(message: String?)` case where `nil` meant the 107 + /// former. With the silent variant separated, the aggregator can 108 + /// never accidentally overwrite a script-failure alert that 109 + /// shares a batch with a trash failure. 110 + enum RemovalOutcome: Equatable, Sendable { 111 + /// Target completed cleanly. Aggregator appends to 112 + /// `batch.succeeded` and the terminal `.repositoriesRemoved` 113 + /// prunes it from state. 114 + case success 115 + /// Target failed AND the caller has already set `state.alert` 116 + /// (e.g. blocking-script failure, user-cancelled confirm, 117 + /// kind-flip after `git init`). Aggregator drains the target 118 + /// without touching `state.alert` and without contributing to 119 + /// the consolidated trash-failure alert. 120 + case failureSilent 121 + /// Target failed with a user-facing explanation the aggregator 122 + /// should include in the consolidated alert (primarily 123 + /// `FileManager.trashItem` errors). The aggregator coalesces 124 + /// parallel `.failureWithMessage` outcomes into a single alert 125 + /// so each failure doesn't overwrite the last via per-target 126 + /// `.presentAlert` races. 127 + case failureWithMessage(String) 128 + } 129 + 130 + /// Git-only sidebar actions that can be dispatched against a 131 + /// folder row (hotkey, deeplink). Drives 132 + /// `folderIncompatibleAlert` so every entry point presents the 133 + /// same precise copy ("Archive only applies to git repositories.") 134 + /// instead of a generic "Action not available." Shared across 135 + /// this feature's hotkey handlers AND the `AppFeature` deeplink 136 + /// layer so the copy can't drift between entry points. 137 + enum FolderIncompatibleAction: Equatable, Sendable { 138 + case archive 139 + case unarchive 140 + case pin 141 + case unpin 142 + 143 + var displayName: String { 144 + switch self { 145 + case .archive: "Archive" 146 + case .unarchive: "Unarchive" 147 + case .pin: "Pin" 148 + case .unpin: "Unpin" 149 + } 150 + } 151 + 152 + /// Single source of truth for the "git-only action dispatched 153 + /// against a folder" alert copy. Both the reducer's 154 + /// `folderIncompatibleAlert` helper and `AppFeature`'s 155 + /// deeplink folder-rejection handler consume this — the 156 + /// `AlertState<Alert>` shape diverges between the two features 157 + /// (different `Alert` action types), but the title / message 158 + /// strings must stay identical so users see the same wording 159 + /// regardless of entry point. 160 + var alertCopy: (title: String, message: String) { 161 + ("\(displayName) not available", "\(displayName) only applies to git repositories.") 162 + } 163 + } 164 + } 165 + 166 + extension RepositoriesFeature { 167 + /// Shared failure tail for `.deleteScriptCompleted` cancel / 168 + /// non-zero-exit branches. Folder removals drain the batch so 169 + /// bulk aggregations don't hang; the aggregator is responsible 170 + /// for clearing `removingRepositoryIDs` on failure (lookup needs 171 + /// the record to find the batch). Git worktree deletes have no 172 + /// repo-level record so this is a no-op for them. 173 + /// 174 + /// Resolves the owning repo id from stored state 175 + /// (`removingRepositoryIDs`) rather than `state.repositories`, 176 + /// so a concurrent reload / `.removeFailedRepository` race that 177 + /// pruned the live repo mid-script can't orphan the batch. 178 + /// Folder worktrees follow the `"folder:" + repoID` convention 179 + /// (see `Repository.folderWorktreeID(for:)`). Round-trip back to 180 + /// the repo id via `Repository.repositoryID(fromFolderWorktreeID:)` 181 + /// so the prefix literal lives in exactly one place. 182 + func signalFolderRemovalFailure( 183 + worktreeID: Worktree.ID, 184 + state: inout State 185 + ) -> Effect<Action> { 186 + guard let repositoryID = Repository.repositoryID(fromFolderWorktreeID: worktreeID), 187 + state.removingRepositoryIDs[repositoryID]?.disposition.isFolder == true 188 + else { return .none } 189 + return .send( 190 + .repositoryRemovalCompleted( 191 + repositoryID, outcome: .failureSilent, selectionWasRemoved: false)) 192 + } 193 + 194 + /// Shared "Action not available" alert shown when a git-only 195 + /// action (archive / pin / unpin) is dispatched against a 196 + /// folder repository. Four call sites produced the same 197 + /// `AlertState` inline before this helper existed — now they 198 + /// share one construction so the copy can't drift. 199 + func folderIncompatibleAlert(action: FolderIncompatibleAction) -> AlertState<Alert> { 200 + let copy = action.alertCopy 201 + return messageAlert(title: copy.title, message: copy.message) 202 + } 203 + 204 + /// Consolidated alert shown when one or more folder trashes 205 + /// fail within the same batch. Single-target: plain "Delete 206 + /// from disk failed" with the one error message (same UX as 207 + /// before). Multi-target: titled with the count, body lists 208 + /// each failing folder's name + error so users can see which 209 + /// folders stayed on disk and why. 210 + /// 211 + /// `namesByRepositoryID` is resolved by the aggregator at drain 212 + /// time (BEFORE `.repositoriesRemoved` prunes state) so the 213 + /// alert shows stable, user-recognizable folder names even if a 214 + /// concurrent reload removes the repo from `state.repositories` 215 + /// between the failure signal and the alert construction. Both 216 + /// fallback paths use the last path component of the repo id so 217 + /// single-target and multi-target copy stay visually consistent. 218 + func consolidatedTrashFailureAlert( 219 + failureMessagesByRepositoryID: [Repository.ID: String], 220 + namesByRepositoryID: [Repository.ID: String] 221 + ) -> AlertState<Alert> { 222 + func displayName(for id: Repository.ID) -> String { 223 + if let resolved = namesByRepositoryID[id], !resolved.isEmpty { 224 + return resolved 225 + } 226 + let fallback = URL(fileURLWithPath: id).lastPathComponent 227 + return fallback.isEmpty ? id : fallback 228 + } 229 + let count = failureMessagesByRepositoryID.count 230 + if count == 1, let (id, message) = failureMessagesByRepositoryID.first { 231 + return messageAlert( 232 + title: "Delete from disk failed", 233 + message: "Couldn't move \(displayName(for: id)) to the Trash: \(message)" 234 + ) 235 + } 236 + let lines = 237 + failureMessagesByRepositoryID 238 + .map { id, message -> String in "• \(displayName(for: id)): \(message)" } 239 + .sorted() 240 + .joined(separator: "\n") 241 + return messageAlert( 242 + title: "Delete from disk failed for \(count) folders", 243 + message: "These folders stayed on disk:\n\n\(lines)" 244 + ) 245 + } 246 + 247 + func folderRemovalEffect( 248 + repositoryID: Repository.ID, 249 + selectionWasRemoved: Bool, 250 + diskDeletionURL: URL? 251 + ) -> Effect<Action> { 252 + // Completion always routes through `.repositoryRemovalCompleted` 253 + // so the batch aggregator can decide whether to fire the bulk 254 + // terminal. For the trash path, the effect awaits the trash 255 + // operation before reporting completion — on failure we pass 256 + // the localized error message via 257 + // `RemovalOutcome.failureWithMessage` so the aggregator can 258 + // coalesce parallel failures into a single alert instead of 259 + // each overwriting `state.alert`. 260 + guard let diskDeletionURL else { 261 + return .send( 262 + .repositoryRemovalCompleted( 263 + repositoryID, outcome: .success, selectionWasRemoved: selectionWasRemoved)) 264 + } 265 + return .run { send in 266 + do { 267 + try await Task.detached { 268 + try FileManager.default.trashItem(at: diskDeletionURL, resultingItemURL: nil) 269 + }.value 270 + await send( 271 + .repositoryRemovalCompleted( 272 + repositoryID, outcome: .success, selectionWasRemoved: selectionWasRemoved)) 273 + } catch { 274 + repositoriesLogger.warning( 275 + "Failed to trash folder at \(diskDeletionURL.path(percentEncoded: false)): " 276 + + error.localizedDescription 277 + ) 278 + await send( 279 + .repositoryRemovalCompleted( 280 + repositoryID, 281 + outcome: .failureWithMessage(error.localizedDescription), 282 + selectionWasRemoved: false 283 + )) 284 + } 285 + } 286 + } 287 + 288 + func confirmationAlertForRepositoryRemoval( 289 + repositoryID: Repository.ID, 290 + state: State 291 + ) -> AlertState<Alert>? { 292 + guard let repository = state.repositories[id: repositoryID] else { 293 + return nil 294 + } 295 + let isGitRepository = repository.isGitRepository 296 + return AlertState { 297 + TextState(isGitRepository ? "Remove repository?" : "Remove folder?") 298 + } actions: { 299 + ButtonState(role: .destructive, action: .confirmDeleteRepository(repository.id)) { 300 + TextState(isGitRepository ? "Remove repository" : "Remove folder") 301 + } 302 + ButtonState(role: .cancel) { 303 + TextState("Cancel") 304 + } 305 + } message: { 306 + TextState( 307 + isGitRepository 308 + ? "This removes the repository from Supacode. " 309 + + "The repository and its worktrees stay on disk." 310 + : "This removes the folder from Supacode. The folder stays on disk." 311 + ) 312 + } 313 + } 314 + 315 + /// Narrow generic message alert used by every dispatched alert 316 + /// path in this reducer (presentAlert, folder-incompatible, 317 + /// trash-failure fallback, delete-not-found). Lives here instead 318 + /// of on the main reducer so helpers in this extension can use 319 + /// it without exposing more private surface. 320 + func messageAlert(title: String, message: String) -> AlertState<Alert> { 321 + AlertState { 322 + TextState(title) 323 + } actions: { 324 + ButtonState(role: .cancel) { 325 + TextState("OK") 326 + } 327 + } message: { 328 + TextState(message) 329 + } 330 + } 331 + }
+919 -240
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 9 9 10 10 private enum CancelID { 11 11 static let load = "repositories.load" 12 + static let persistRoots = "repositories.persistRoots" 12 13 static let toastAutoDismiss = "repositories.toastAutoDismiss" 13 14 static let githubIntegrationAvailability = "repositories.githubIntegrationAvailability" 14 15 static let githubIntegrationRecovery = "repositories.githubIntegrationRecovery" ··· 19 20 } 20 21 } 21 22 22 - private nonisolated let repositoriesLogger = SupaLogger("Repositories") 23 + nonisolated let repositoriesLogger = SupaLogger("Repositories") 23 24 private nonisolated let githubIntegrationRecoveryInterval: Duration = .seconds(15) 24 25 private nonisolated let worktreeCreationProgressLineLimit = 200 25 26 private nonisolated let worktreeCreationProgressUpdateStride = 20 ··· 80 81 var archivingWorktreeIDs: Set<Worktree.ID> = [] 81 82 var deleteScriptWorktreeIDs: Set<Worktree.ID> = [] 82 83 var deletingWorktreeIDs: Set<Worktree.ID> = [] 83 - var removingRepositoryIDs: Set<Repository.ID> = [] 84 + /// Repositories with an in-flight removal. The value records 85 + /// the removal intent confirmed for this repo. 86 + /// `.deleteScriptCompleted` routes by the stored intent rather 87 + /// than by live kind classification (which a `git init` 88 + /// mid-delete could flip). An empty key means no removal; 89 + /// presence also drives the sidebar's "removing" indicator. 90 + /// In-flight repo-level removals keyed by repository id. Each 91 + /// record carries the disposition (which only ever holds 92 + /// `.gitRepositoryUnlink` / `.folderUnlink` / `.folderTrash` — 93 + /// the per-worktree `.gitWorktreeDelete` flow uses 94 + /// `deletingWorktreeIDs` instead) and the id of the batch 95 + /// aggregator responsible for draining its per-target 96 + /// completion. Folding disposition + batch id into one record 97 + /// keeps them in lockstep: a repo can't be "being removed" 98 + /// without an owning batch, and a batch always knows the 99 + /// disposition of each of its targets. 100 + var removingRepositoryIDs: [Repository.ID: RepositoryRemovalRecord] = [:] 101 + /// Bulk-removal aggregators keyed by batch id. Populated by the 102 + /// confirm handler for repo-level deletes (folder rows + git-repo 103 + /// section removals). As each per-target completion arrives via 104 + /// `.repositoryRemovalCompleted`, its id is drained from 105 + /// `pending` and (if succeeded) appended to `succeeded`. The 106 + /// batch fires a single `.repositoriesRemoved([ids], ...)` when 107 + /// `pending` is empty, replacing the per-target reloads that 108 + /// previously raced through `CancelID.persistRoots`. The dict 109 + /// (rather than a single optional) lets overlapping removals — 110 + /// e.g. a folder bulk trash in-flight while the user confirms a 111 + /// git-repo section remove — each complete independently 112 + /// without clobbering each other's pending set. 113 + var activeRemovalBatches: [BatchID: ActiveRemovalBatch] = [:] 84 114 var autoDeleteArchivedWorktreesAfterDays: AutoDeletePeriod? 85 115 var mergedWorktreeAction: MergedWorktreeAction? 86 116 var moveNotifiedWorktreeToTop = true ··· 107 137 @Presents var alert: AlertState<Alert>? 108 138 } 109 139 140 + // Removal pipeline types + helpers live in 141 + // `RepositoriesFeature+Removal.swift` — see that file for 142 + // `DeleteDisposition`, `RepositoryRemovalRecord`, 143 + // `ActiveRemovalBatch`, `FolderIncompatibleAction`, `BatchID`, 144 + // and the `folderRemovalEffect` / `signalFolderRemovalFailure` 145 + // / `folderIncompatibleAlert` / `consolidatedTrashFailureAlert` 146 + // / `confirmationAlertForRepositoryRemoval` / `messageAlert` 147 + // helpers the reducer body below calls into. 148 + 110 149 enum GithubIntegrationAvailability: Equatable { 111 150 case unknown 112 151 case checking ··· 206 245 case archiveScriptCompleted(worktreeID: Worktree.ID, exitCode: Int?, tabId: TerminalTabID?) 207 246 case archiveWorktreeApply(Worktree.ID, Repository.ID) 208 247 case unarchiveWorktree(Worktree.ID) 209 - case requestDeleteWorktree(Worktree.ID, Repository.ID) 210 - case requestDeleteWorktrees([DeleteWorktreeTarget]) 211 - case deleteWorktreeConfirmed(Worktree.ID, Repository.ID) 248 + case requestDeleteSidebarItems([DeleteWorktreeTarget]) 249 + case deleteSidebarItemConfirmed(Worktree.ID, Repository.ID) 212 250 case deleteScriptCompleted(worktreeID: Worktree.ID, exitCode: Int?, tabId: TerminalTabID?) 213 251 case deleteWorktreeApply(Worktree.ID, Repository.ID) 214 252 case worktreeDeleted( ··· 221 259 case pinnedWorktreesMoved(repositoryID: Repository.ID, IndexSet, Int) 222 260 case unpinnedWorktreesMoved(repositoryID: Repository.ID, IndexSet, Int) 223 261 case deleteWorktreeFailed(String, worktreeID: Worktree.ID) 224 - case requestRemoveRepository(Repository.ID) 262 + case requestDeleteRepository(Repository.ID) 225 263 case removeFailedRepository(Repository.ID) 226 - case repositoryRemoved(Repository.ID, selectionWasRemoved: Bool) 264 + /// Per-target signal feeding the batch aggregator. Every 265 + /// repo-level removal path (folder via delete pipeline, 266 + /// git-repo section-level) emits one of these when the target's 267 + /// per-item work concludes. `.failure` covers script failures 268 + /// / cancellations / kind-flip / trash failures so a bulk 269 + /// batch drains even when individual targets fail. `.failure` 270 + /// with a `message` is collected by the aggregator and 271 + /// surfaced in a consolidated alert once the batch finishes — 272 + /// so N parallel trash failures don't each clobber 273 + /// `state.alert`. 274 + case repositoryRemovalCompleted( 275 + Repository.ID, 276 + outcome: RemovalOutcome, 277 + selectionWasRemoved: Bool 278 + ) 279 + /// Bulk terminal: fired exactly once per batch after every 280 + /// target's `.repositoryRemovalCompleted` has been collected. 281 + /// Replaces the per-target `.repositoryRemoved` that raced on 282 + /// `.repositoriesLoaded`. For single-item paths the batch has 283 + /// size 1 — same code. 284 + case repositoriesRemoved([Repository.ID], selectionWasRemoved: Bool) 227 285 case pinWorktree(Worktree.ID) 228 286 case unpinWorktree(Worktree.ID) 229 287 case presentAlert(title: String, message: String) ··· 281 339 enum Alert: Equatable { 282 340 case confirmArchiveWorktree(Worktree.ID, Repository.ID) 283 341 case confirmArchiveWorktrees([ArchiveWorktreeTarget]) 284 - case confirmDeleteWorktree(Worktree.ID, Repository.ID) 285 - case confirmDeleteWorktrees([DeleteWorktreeTarget]) 286 - case confirmRemoveRepository(Repository.ID) 342 + case confirmDeleteSidebarItems([DeleteWorktreeTarget], disposition: DeleteDisposition) 343 + case confirmDeleteRepository(Repository.ID) 287 344 case viewTerminalTab(Worktree.ID, tabId: TerminalTabID) 288 345 } 289 346 ··· 357 414 return .send(.reloadRepositories(animated: false)) 358 415 359 416 case .reloadRepositories(let animated): 360 - state.alert = nil 417 + // Deliberately NOT clearing `state.alert` here — 418 + // `.reloadRepositories` is a data-layer refresh and fires 419 + // from both user intents (refresh hotkey) and downstream of 420 + // delete/archive flows. Wiping a just-set terminal alert 421 + // (e.g. the consolidated trash-failure alert the aggregator 422 + // set before firing `.repositoriesRemoved` → `.repositoriesLoaded` 423 + // → `.autoDeleteExpiredArchivedWorktrees`) was the source 424 + // of an observable "failure alert vanishes on the same 425 + // tick" bug. Confirmation-style alerts are already cleared 426 + // by their own confirm handlers upstream of this action. 361 427 let roots = state.repositoryRoots 362 428 guard !roots.isEmpty else { 363 429 state.isRefreshingWorktrees = false ··· 419 485 let root = try await gitClient.repoRoot(url) 420 486 resolvedRoots.append(root) 421 487 } catch { 422 - invalidRoots.append(url.path(percentEncoded: false)) 488 + // `gitClient.repoRoot` throws for non-git paths, but 489 + // also for transient `wt` / subprocess failures. To 490 + // avoid silently reclassifying a git repo as a folder 491 + // on transient errors, double-check via the injected 492 + // `gitClient.isGitRepository` — if the path actually 493 + // has `.git`, surface the original error as an invalid 494 + // root. Non-git readable directories are accepted as 495 + // folder-kind repositories. 496 + let standardized = url.standardizedFileURL 497 + var isDirectory: ObjCBool = false 498 + let exists = FileManager.default.fileExists( 499 + atPath: standardized.path(percentEncoded: false), 500 + isDirectory: &isDirectory 501 + ) 502 + if exists, isDirectory.boolValue, 503 + await !gitClient.isGitRepository(standardized) 504 + { 505 + resolvedRoots.append(standardized) 506 + } else { 507 + invalidRoots.append(url.path(percentEncoded: false)) 508 + } 423 509 } 424 510 } 425 511 let resolvedRootPaths = RepositoryPathNormalizer.normalize( ··· 457 543 uniqueKeysWithValues: failures.map { ($0.rootID, $0.message) } 458 544 ) 459 545 if !invalidRoots.isEmpty { 460 - let message = invalidRoots.map { "\($0) is not a Git repository." }.joined(separator: "\n") 546 + let message = invalidRoots.map { "Supacode couldn't read \($0)." }.joined(separator: "\n") 461 547 state.alert = messageAlert( 462 - title: "Some folders couldn't be opened", 548 + title: "Some items couldn't be opened", 463 549 message: message 464 550 ) 465 551 } ··· 505 591 return .send(.delegate(.selectedWorktreeChanged(nil))) 506 592 507 593 case .setSidebarSelectedWorktreeIDs(let worktreeIDs): 508 - let validWorktreeIDs = Set(state.orderedWorktreeRows().map(\.id)) 594 + let validWorktreeIDs = Set(state.orderedSidebarItems().map(\.id)) 509 595 var nextWorktreeIDs = worktreeIDs.intersection(validWorktreeIDs) 510 596 if let selectedWorktreeID = state.selectedWorktreeID, validWorktreeIDs.contains(selectedWorktreeID) { 511 597 nextWorktreeIDs.insert(selectedWorktreeID) ··· 603 689 ) 604 690 return .none 605 691 } 606 - if state.removingRepositoryIDs.contains(repository.id) { 692 + // Worktree creation needs a git repository. Folder-kind entries 693 + // surface the same menu / hotkey / deeplink path, so reject 694 + // them up front with a clear alert instead of letting the 695 + // request fall into `gitClient.createWorktreeStream` and fail 696 + // with a raw subprocess error. 697 + if !repository.isGitRepository { 698 + state.alert = messageAlert( 699 + title: "Unable to create worktree", 700 + message: "Worktrees are only supported for git repositories." 701 + ) 702 + return .none 703 + } 704 + if state.removingRepositoryIDs[repository.id] != nil { 607 705 state.alert = messageAlert( 608 706 title: "Unable to create worktree", 609 707 message: "This repository is being removed." ··· 785 883 ) 786 884 return .none 787 885 } 788 - if state.removingRepositoryIDs.contains(repository.id) { 886 + // Guard against folder-kind entries arriving here via 887 + // deeplink / palette paths that bypass 888 + // `.createRandomWorktreeInRepository`. 889 + if !repository.isGitRepository { 890 + state.alert = messageAlert( 891 + title: "Unable to create worktree", 892 + message: "Worktrees are only supported for git repositories." 893 + ) 894 + return .none 895 + } 896 + if state.removingRepositoryIDs[repository.id] != nil { 789 897 state.alert = messageAlert( 790 898 title: "Unable to create worktree", 791 899 message: "This repository is being removed." ··· 1186 1294 return .none 1187 1295 1188 1296 case .requestArchiveWorktree(let worktreeID, let repositoryID): 1189 - if state.removingRepositoryIDs.contains(repositoryID) { 1297 + if state.removingRepositoryIDs[repositoryID] != nil { 1190 1298 return .none 1191 1299 } 1192 1300 guard let repository = state.repositories[id: repositoryID], ··· 1194 1302 else { 1195 1303 return .none 1196 1304 } 1305 + // Folder repos have a synthesized main-worktree; archive 1306 + // targets it via `isMainWorktree` geometry. Surface the 1307 + // `folderIncompatibleAlert` feedback the deeplink layer 1308 + // already shows so hotkeys don't silently no-op. 1309 + if !repository.isGitRepository { 1310 + state.alert = folderIncompatibleAlert(action: .archive) 1311 + return .none 1312 + } 1197 1313 if state.isMainWorktree(worktree) { 1198 1314 return .none 1199 1315 } ··· 1236 1352 var seenWorktreeIDs: Set<Worktree.ID> = [] 1237 1353 for target in targets { 1238 1354 guard seenWorktreeIDs.insert(target.worktreeID).inserted else { continue } 1239 - if state.removingRepositoryIDs.contains(target.repositoryID) { 1355 + if state.removingRepositoryIDs[target.repositoryID] != nil { 1240 1356 continue 1241 1357 } 1242 1358 guard let repository = state.repositories[id: target.repositoryID], ··· 1433 1549 let repositories = state.repositories 1434 1550 return .send(.delegate(.repositoriesChanged(repositories))) 1435 1551 1436 - case .requestDeleteWorktree(let worktreeID, let repositoryID): 1437 - if state.removingRepositoryIDs.contains(repositoryID) { 1438 - return .none 1439 - } 1440 - guard let repository = state.repositories[id: repositoryID], 1441 - let worktree = repository.worktrees[id: worktreeID] 1442 - else { 1443 - return .none 1444 - } 1445 - if state.isMainWorktree(worktree) { 1446 - state.alert = messageAlert( 1447 - title: "Delete not allowed", 1448 - message: "Deleting the main worktree is not allowed." 1449 - ) 1450 - return .none 1552 + case .requestDeleteSidebarItems(let targets): 1553 + // Kind discriminator: folders skip the main-worktree guard 1554 + // (their synthetic worktree IS main). Mixed kind selections 1555 + // get rejected — the context menu already blocks mixed 1556 + // bulk, so this only trips if a hotkey somehow routes a 1557 + // heterogeneous selection here. 1558 + var validTargets: [DeleteWorktreeTarget] = [] 1559 + var validKinds: Set<SidebarItemModel.Kind> = [] 1560 + var seenWorktreeIDs: Set<Worktree.ID> = [] 1561 + var rejectedMainWorktreeCount = 0 1562 + for target in targets { 1563 + guard seenWorktreeIDs.insert(target.worktreeID).inserted, 1564 + state.removingRepositoryIDs[target.repositoryID] == nil, 1565 + let repository = state.repositories[id: target.repositoryID], 1566 + let worktree = repository.worktrees[id: target.worktreeID], 1567 + !state.deletingWorktreeIDs.contains(worktree.id), 1568 + !state.deleteScriptWorktreeIDs.contains(worktree.id), 1569 + !state.archivingWorktreeIDs.contains(worktree.id) 1570 + else { continue } 1571 + if repository.isGitRepository { 1572 + if state.isMainWorktree(worktree) { 1573 + rejectedMainWorktreeCount += 1 1574 + continue 1575 + } 1576 + validKinds.insert(.git) 1577 + } else { 1578 + validKinds.insert(.folder) 1579 + } 1580 + validTargets.append(target) 1451 1581 } 1452 - if state.archivingWorktreeIDs.contains(worktree.id) { 1582 + guard !validTargets.isEmpty, validKinds.count == 1 else { 1583 + // Single-target main-worktree rejection: surface the same 1584 + // "Delete not allowed" feedback the deeplink path already 1585 + // shows, so palette / hotkey / context-menu entries behave 1586 + // consistently instead of silently no-opping. 1587 + if targets.count == 1, validTargets.isEmpty, rejectedMainWorktreeCount == 1 { 1588 + state.alert = messageAlert( 1589 + title: "Delete not allowed", 1590 + message: "Deleting the main worktree is not allowed." 1591 + ) 1592 + } 1453 1593 return .none 1454 1594 } 1455 - if state.deletingWorktreeIDs.contains(worktree.id) 1456 - || state.deleteScriptWorktreeIDs.contains(worktree.id) 1457 - { 1595 + let count = validTargets.count 1596 + if validKinds == [.folder] { 1597 + let folders = validTargets.compactMap { state.repositories[id: $0.repositoryID] } 1598 + let namesList = folders.map(\.name) 1599 + .sorted(by: { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }) 1600 + .joined(separator: ", ") 1601 + let title = count == 1 ? "Remove folder?" : "Remove \(count) folders?" 1602 + let messageSubject = count == 1 ? folders.first?.name ?? "this folder" : namesList 1603 + let stayOnDiskCopy = 1604 + count == 1 1605 + ? "managing the folder (it stays on disk)" 1606 + : "managing the folders (they stay on disk)" 1607 + let trashCopy = 1608 + count == 1 ? "move the folder to the Trash" : "move them to the Trash" 1609 + state.alert = AlertState { 1610 + TextState(title) 1611 + } actions: { 1612 + ButtonState( 1613 + action: .confirmDeleteSidebarItems(validTargets, disposition: .folderUnlink) 1614 + ) { 1615 + TextState("Remove from Supacode") 1616 + } 1617 + ButtonState( 1618 + role: .destructive, 1619 + action: .confirmDeleteSidebarItems(validTargets, disposition: .folderTrash) 1620 + ) { 1621 + TextState("Delete from disk") 1622 + } 1623 + ButtonState(role: .cancel) { 1624 + TextState("Cancel") 1625 + } 1626 + } message: { 1627 + TextState( 1628 + "Remove \(messageSubject)? Choose \"Remove from Supacode\" to stop " 1629 + + stayOnDiskCopy 1630 + + ", or \"Delete from disk\" to " + trashCopy + "." 1631 + ) 1632 + } 1458 1633 return .none 1459 1634 } 1460 1635 @Shared(.settingsFile) var settingsFile 1461 1636 let deleteBranchOnDeleteWorktree = settingsFile.global.deleteBranchOnDeleteWorktree 1462 - let removalMessage = 1463 - deleteBranchOnDeleteWorktree 1464 - ? "This deletes the worktree directory and its local branch." 1465 - : "This deletes the worktree directory and keeps the local branch." 1637 + let removalSubject = 1638 + count == 1 1639 + ? "the worktree directory and " 1640 + + (deleteBranchOnDeleteWorktree ? "its local branch" : "keep the local branch") 1641 + : "the worktree directories and " 1642 + + (deleteBranchOnDeleteWorktree ? "their local branches" : "keep their local branches") 1643 + let title = count == 1 ? "🚨 Delete worktree?" : "🚨 Delete \(count) worktrees?" 1644 + let buttonLabel = count == 1 ? "Delete (⌘↩)" : "Delete \(count) (⌘↩)" 1645 + let singleTargetName = 1646 + validTargets.first.flatMap { 1647 + state.repositories[id: $0.repositoryID]?.worktrees[id: $0.worktreeID]?.name 1648 + } 1649 + let messageSubject = 1650 + count == 1 1651 + ? "Delete \(singleTargetName ?? "worktree")?" 1652 + : "Delete \(count) worktrees?" 1466 1653 state.alert = AlertState { 1467 - TextState("🚨 Delete worktree?") 1654 + TextState(title) 1468 1655 } actions: { 1469 - ButtonState(role: .destructive, action: .confirmDeleteWorktree(worktree.id, repository.id)) { 1470 - TextState("Delete (⌘↩)") 1656 + ButtonState( 1657 + role: .destructive, 1658 + action: .confirmDeleteSidebarItems(validTargets, disposition: .gitWorktreeDelete) 1659 + ) { 1660 + TextState(buttonLabel) 1471 1661 } 1472 1662 ButtonState(role: .cancel) { 1473 1663 TextState("Cancel") 1474 1664 } 1475 1665 } message: { 1476 - TextState("Delete \(worktree.name)? " + removalMessage) 1666 + TextState("\(messageSubject) This deletes \(removalSubject).") 1477 1667 } 1478 1668 return .none 1479 1669 1480 - case .requestDeleteWorktrees(let targets): 1670 + case .alert(.presented(.confirmDeleteSidebarItems(let targets, let disposition))): 1671 + // Kind-and-disposition mapping: folders carry the 1672 + // disposition into `removingRepositoryIDs` so 1673 + // `.deleteScriptCompleted` can route by stored choice later. 1674 + // Git worktrees run the standard per-worktree pipeline and 1675 + // don't record a repo-level disposition. Kind / disposition 1676 + // mismatches are impossible under the current alert surface 1677 + // and a caller bypassing those guards is a bug — flag it via 1678 + // `reportIssue` instead of dropping silently. 1679 + state.alert = nil 1481 1680 var validTargets: [DeleteWorktreeTarget] = [] 1482 - var seenWorktreeIDs: Set<Worktree.ID> = [] 1681 + var folderBatchIDs: Set<Repository.ID> = [] 1483 1682 for target in targets { 1484 - guard seenWorktreeIDs.insert(target.worktreeID).inserted else { continue } 1485 - if state.removingRepositoryIDs.contains(target.repositoryID) { 1486 - continue 1487 - } 1488 1683 guard let repository = state.repositories[id: target.repositoryID], 1489 - let worktree = repository.worktrees[id: target.worktreeID] 1490 - else { 1491 - continue 1492 - } 1493 - if state.isMainWorktree(worktree) 1494 - || state.deletingWorktreeIDs.contains(worktree.id) 1495 - || state.deleteScriptWorktreeIDs.contains(worktree.id) 1496 - || state.archivingWorktreeIDs.contains(worktree.id) 1497 - { 1498 - continue 1684 + state.removingRepositoryIDs[target.repositoryID] == nil 1685 + else { continue } 1686 + if repository.isGitRepository { 1687 + guard disposition == .gitWorktreeDelete else { 1688 + reportIssue( 1689 + """ 1690 + confirmDeleteSidebarItems: received \(disposition) for git worktree \ 1691 + \(target.worktreeID) — git targets only support .gitWorktreeDelete. \ 1692 + Dropping target. 1693 + """ 1694 + ) 1695 + continue 1696 + } 1697 + } else { 1698 + guard disposition.isFolder else { 1699 + reportIssue( 1700 + """ 1701 + confirmDeleteSidebarItems: received \(disposition) for folder \ 1702 + \(target.repositoryID) — folder targets only support .folderUnlink / \ 1703 + .folderTrash. Dropping target. 1704 + """ 1705 + ) 1706 + continue 1707 + } 1708 + folderBatchIDs.insert(target.repositoryID) 1499 1709 } 1500 1710 validTargets.append(target) 1501 1711 } 1502 - guard !validTargets.isEmpty else { 1503 - return .none 1504 - } 1505 - @Shared(.settingsFile) var settingsFile 1506 - let deleteBranchOnDeleteWorktree = settingsFile.global.deleteBranchOnDeleteWorktree 1507 - let removalMessage = 1508 - deleteBranchOnDeleteWorktree 1509 - ? "This deletes the worktree directories and their local branches." 1510 - : "This deletes the worktree directories and keeps their local branches." 1511 - let count = validTargets.count 1512 - state.alert = AlertState { 1513 - TextState("🚨 Delete \(count) worktrees?") 1514 - } actions: { 1515 - ButtonState(role: .destructive, action: .confirmDeleteWorktrees(validTargets)) { 1516 - TextState("Delete \(count) (⌘↩)") 1712 + guard !validTargets.isEmpty else { return .none } 1713 + if !folderBatchIDs.isEmpty { 1714 + // All folder targets in this batch share the same 1715 + // disposition (the alert only ever produces one), so one 1716 + // record shape per repo keeps disposition + batch id in 1717 + // lockstep. 1718 + let batchID = uuid() 1719 + for repositoryID in folderBatchIDs { 1720 + state.removingRepositoryIDs[repositoryID] = RepositoryRemovalRecord( 1721 + disposition: disposition, batchID: batchID 1722 + ) 1517 1723 } 1518 - ButtonState(role: .cancel) { 1519 - TextState("Cancel") 1520 - } 1521 - } message: { 1522 - TextState("Delete \(count) worktrees? " + removalMessage) 1724 + state.activeRemovalBatches[batchID] = 1725 + ActiveRemovalBatch(id: batchID, pending: folderBatchIDs) 1523 1726 } 1524 - return .none 1525 - 1526 - case .alert(.presented(.confirmDeleteWorktree(let worktreeID, let repositoryID))): 1527 - return .send(.deleteWorktreeConfirmed(worktreeID, repositoryID)) 1528 - 1529 - case .alert(.presented(.confirmDeleteWorktrees(let targets))): 1530 1727 return .merge( 1531 - targets.map { target in 1532 - .send(.deleteWorktreeConfirmed(target.worktreeID, target.repositoryID)) 1728 + validTargets.map { 1729 + .send(.deleteSidebarItemConfirmed($0.worktreeID, $0.repositoryID)) 1533 1730 } 1534 1731 ) 1535 1732 1536 - case .deleteWorktreeConfirmed(let worktreeID, let repositoryID): 1733 + case .deleteSidebarItemConfirmed(let worktreeID, let repositoryID): 1537 1734 guard let repository = state.repositories[id: repositoryID], 1538 1735 let worktree = repository.worktrees[id: worktreeID] 1539 1736 else { 1540 1737 repositoriesLogger.debug( 1541 - "deleteWorktreeConfirmed: worktree \(worktreeID) not found in repository \(repositoryID)." 1738 + "deleteSidebarItemConfirmed: worktree \(worktreeID) not found in repository \(repositoryID)." 1542 1739 ) 1543 1740 return .none 1544 1741 } 1545 - if state.archivingWorktreeIDs.contains(worktree.id) { 1742 + // `deletingWorktreeIDs` / `deleteScriptWorktreeIDs` guard 1743 + // against re-entry for both git worktrees and folders — 1744 + // the empty-script folder branch below populates 1745 + // `deletingWorktreeIDs` so a rapid repeat lands here as a 1746 + // no-op. The first in-flight tap's 1747 + // `.repositoryRemovalCompleted` is the one that drains 1748 + // the aggregator batch; draining here as well would 1749 + // double-drain `batch.pending` and orphan the first tap's 1750 + // completion into the `reportIssue` path. 1751 + if state.archivingWorktreeIDs.contains(worktree.id) 1752 + || state.deletingWorktreeIDs.contains(worktree.id) 1753 + || state.deleteScriptWorktreeIDs.contains(worktree.id) 1754 + { 1546 1755 return .none 1547 1756 } 1548 - if state.deletingWorktreeIDs.contains(worktree.id) 1549 - || state.deleteScriptWorktreeIDs.contains(worktree.id) 1757 + // F4: folder targets only arrive here after the alert's 1758 + // confirm handler seeded a `RepositoryRemovalRecord`. If a 1759 + // future caller short-circuits to this action without going 1760 + // through `.requestDeleteSidebarItems` → confirm, the 1761 + // aggregator would never drain. Flag the invariant breach 1762 + // loudly (tests fail, release warns) and bail out early so 1763 + // we don't fall through to the git-worktree delete path for 1764 + // a folder. 1765 + if !repository.isGitRepository, 1766 + state.removingRepositoryIDs[repository.id] == nil 1550 1767 { 1768 + reportIssue( 1769 + """ 1770 + deleteSidebarItemConfirmed: folder \(repository.id) missing seeded removal \ 1771 + record. Callers must go through .requestDeleteSidebarItems → \ 1772 + .confirmDeleteSidebarItems so the batch aggregator is set up. 1773 + """ 1774 + ) 1551 1775 return .none 1552 1776 } 1553 - state.alert = nil 1777 + // NOTE: we do NOT clear `state.alert` here. 1778 + // - Alert-confirmed path: `.confirmDeleteSidebarItems` 1779 + // already cleared its own confirm alert at entry. 1780 + // - Auto-delete / merged-sweep path: this action fires 1781 + // programmatically; an unconditional clear here would 1782 + // wipe unrelated alerts — specifically the consolidated 1783 + // trash-failure alert just set by the batch aggregator. 1784 + // - Deeplink path: same — the caller decides alert state. 1554 1785 @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings 1555 1786 let script = repositorySettings.deleteScript 1556 1787 let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 1788 + // Only folder-row intents (`.folderUnlink` / `.folderTrash`) 1789 + // route through the folder-removal success branch. 1790 + // `.gitRepositoryUnlink` is a concurrent git-repo section 1791 + // removal that has no bearing on this worktree's delete flow. 1792 + // `nil` is a git worktree delete (no repo-level intent). 1793 + let folderIntent: DeleteDisposition? = { 1794 + guard let record = state.removingRepositoryIDs[repository.id], 1795 + record.disposition.isFolder 1796 + else { return nil } 1797 + return record.disposition 1798 + }() 1557 1799 if trimmed.isEmpty { 1800 + if let folderIntent { 1801 + // Empty script: finish the folder flow immediately, 1802 + // trashing the directory first if the user asked for it. 1803 + state.deletingWorktreeIDs.insert(worktree.id) 1804 + let selectionWasRemoved = state.selectedWorktreeID == worktreeID 1805 + let trashURL = folderIntent == .folderTrash ? repository.rootURL : nil 1806 + return folderRemovalEffect( 1807 + repositoryID: repository.id, 1808 + selectionWasRemoved: selectionWasRemoved, 1809 + diskDeletionURL: trashURL 1810 + ) 1811 + } 1558 1812 return .send(.deleteWorktreeApply(worktreeID, repositoryID)) 1559 1813 } 1560 1814 state.deleteScriptWorktreeIDs.insert(worktree.id) ··· 1563 1817 1564 1818 case .deleteScriptCompleted(let worktreeID, let exitCode, let tabId): 1565 1819 guard state.deleteScriptWorktreeIDs.contains(worktreeID) else { 1566 - repositoriesLogger.debug("Ignoring deleteScriptCompleted for \(worktreeID): not in deleteScriptWorktreeIDs") 1820 + repositoriesLogger.debug( 1821 + "Ignoring deleteScriptCompleted for \(worktreeID): not in deleteScriptWorktreeIDs." 1822 + ) 1567 1823 return .none 1568 1824 } 1569 1825 state.deleteScriptWorktreeIDs.remove(worktreeID) 1826 + // Route by recorded intent, not live classification — a 1827 + // `git init` mid-script would otherwise flip the check and 1828 + // lose folder intent. Kind divergence is treated as an 1829 + // explicit error so the user can decide what to do. 1830 + let owningRepo = state.repositories.first(where: { 1831 + $0.worktrees.contains(where: { $0.id == worktreeID }) 1832 + }) 1833 + // Only a folder-row intent (`.folderUnlink` / `.folderTrash`) 1834 + // routes this completion into repo-level removal. 1835 + // `.gitRepositoryUnlink` is a concurrent git-repo remove 1836 + // running independently; it shouldn't hijack the 1837 + // worktree-delete pipeline. `nil` means plain git worktree 1838 + // delete. 1839 + let folderIntent: DeleteDisposition? = 1840 + owningRepo 1841 + .flatMap { state.removingRepositoryIDs[$0.id] } 1842 + .flatMap { $0.disposition.isFolder ? $0.disposition : nil } 1570 1843 switch exitCode { 1571 1844 case 0: 1572 - guard let repositoryID = state.repositoryID(containing: worktreeID) else { 1573 - repositoriesLogger.warning( 1574 - "Delete script succeeded but repository not found for worktree \(worktreeID)" 1575 - ) 1845 + guard let folderIntent, let owningRepo else { 1846 + guard let repositoryID = state.repositoryID(containing: worktreeID) else { 1847 + // Repo vanished between confirmation and script 1848 + // completion (concurrent reload / remove-failed race). 1849 + // If the worktree id follows the folder-synthetic 1850 + // convention and `removingRepositoryIDs` still holds 1851 + // a folder record, drain the batch via 1852 + // `signalFolderRemovalFailure` so sibling targets 1853 + // don't hang forever; only surface the "Delete 1854 + // failed" alert when no folder record exists. 1855 + if let syntheticRepoID = Repository.repositoryID( 1856 + fromFolderWorktreeID: worktreeID 1857 + ), state.removingRepositoryIDs[syntheticRepoID]?.disposition.isFolder == true { 1858 + repositoriesLogger.warning( 1859 + "Delete script succeeded but repository vanished for folder worktree " 1860 + + "\(worktreeID); draining batch as failure." 1861 + ) 1862 + return signalFolderRemovalFailure(worktreeID: worktreeID, state: &state) 1863 + } 1864 + repositoriesLogger.warning( 1865 + "Delete script succeeded but repository not found for worktree \(worktreeID)" 1866 + ) 1867 + state.alert = messageAlert( 1868 + title: "Delete failed", 1869 + message: "The delete script completed successfully, but the worktree could not be found." 1870 + + " It may have been removed." 1871 + ) 1872 + return .none 1873 + } 1874 + return .send(.deleteWorktreeApply(worktreeID, repositoryID)) 1875 + } 1876 + if owningRepo.isGitRepository { 1877 + // Kind flipped between confirmation and completion — 1878 + // bail out rather than silently picking a path. 1576 1879 state.alert = messageAlert( 1577 - title: "Delete failed", 1578 - message: "The delete script completed successfully, but the worktree could not be found." 1579 - + " It may have been removed." 1880 + title: "Folder is now a git repository", 1881 + message: "Supacode stopped the removal because \(owningRepo.name) became a git " 1882 + + "repository while the delete script was running. Review it and try again." 1580 1883 ) 1581 - return .none 1884 + return signalFolderRemovalFailure(worktreeID: worktreeID, state: &state) 1582 1885 } 1583 - return .send(.deleteWorktreeApply(worktreeID, repositoryID)) 1886 + let selectionWasRemoved = state.selectedWorktreeID == worktreeID 1887 + let trashURL = folderIntent == .folderTrash ? owningRepo.rootURL : nil 1888 + return folderRemovalEffect( 1889 + repositoryID: owningRepo.id, 1890 + selectionWasRemoved: selectionWasRemoved, 1891 + diskDeletionURL: trashURL 1892 + ) 1584 1893 case nil: 1585 - repositoriesLogger.debug("Delete script cancelled or tab closed for worktree \(worktreeID)") 1586 - return .none 1894 + // User closed the script tab. 1895 + repositoriesLogger.debug( 1896 + "Delete script cancelled or tab closed for worktree \(worktreeID).") 1897 + return signalFolderRemovalFailure(worktreeID: worktreeID, state: &state) 1587 1898 case let code?: 1899 + // Script failed. Show the standard failure alert AND — for 1900 + // folder removals — signal the aggregator so bulk batches 1901 + // don't hang waiting for this target. Git worktree delete 1902 + // has no batch. 1588 1903 state.alert = blockingScriptFailureAlert( 1589 1904 kind: .delete, exitCode: code, worktreeID: worktreeID, tabId: tabId, state: state 1590 1905 ) 1591 - return .none 1906 + return signalFolderRemovalFailure(worktreeID: worktreeID, state: &state) 1592 1907 } 1593 1908 1594 1909 case .deleteWorktreeApply(let worktreeID, let repositoryID): ··· 1739 2054 state.alert = messageAlert(title: "Unable to delete worktree", message: message) 1740 2055 return .none 1741 2056 1742 - case .requestRemoveRepository(let repositoryID): 2057 + case .requestDeleteRepository(let repositoryID): 1743 2058 state.alert = confirmationAlertForRepositoryRemoval(repositoryID: repositoryID, state: state) 1744 2059 return .none 1745 2060 ··· 1755 2070 let rootPaths = loadedPaths.filter { seen.insert($0).inserted } 1756 2071 let remaining = rootPaths.filter { $0 != repositoryID } 1757 2072 await repositoryPersistence.saveRoots(remaining) 2073 + await repositoryPersistence.pruneRepositoryConfigs([repositoryID]) 1758 2074 let roots = remaining.map { URL(fileURLWithPath: $0) } 1759 2075 let (repositories, failures) = await loadRepositoriesData(roots) 1760 2076 await send( ··· 1768 2084 } 1769 2085 .cancellable(id: CancelID.load, cancelInFlight: true) 1770 2086 1771 - case .alert(.presented(.confirmRemoveRepository(let repositoryID))): 2087 + case .alert(.presented(.confirmDeleteRepository(let repositoryID))): 1772 2088 guard let repository = state.repositories[id: repositoryID] else { 1773 2089 return .none 1774 2090 } 1775 - if state.removingRepositoryIDs.contains(repository.id) { 2091 + if state.removingRepositoryIDs[repository.id] != nil { 1776 2092 return .none 1777 2093 } 1778 2094 state.alert = nil 1779 - state.removingRepositoryIDs.insert(repository.id) 2095 + // Section-level removal — Supacode never nukes a git repo's 2096 + // on-disk state. No script runs; signal completion 2097 + // immediately and let the aggregator (batch of 1) emit the 2098 + // terminal. 1780 2099 let selectionWasRemoved = 1781 2100 state.selectedWorktreeID.map { id in 1782 2101 repository.worktrees.contains(where: { $0.id == id }) 1783 2102 } ?? false 1784 - return .send(.repositoryRemoved(repository.id, selectionWasRemoved: selectionWasRemoved)) 2103 + let batchID = uuid() 2104 + state.removingRepositoryIDs[repository.id] = RepositoryRemovalRecord( 2105 + disposition: .gitRepositoryUnlink, batchID: batchID 2106 + ) 2107 + state.activeRemovalBatches[batchID] = 2108 + ActiveRemovalBatch(id: batchID, pending: [repository.id]) 2109 + return .send( 2110 + .repositoryRemovalCompleted( 2111 + repository.id, outcome: .success, selectionWasRemoved: selectionWasRemoved)) 2112 + 2113 + case .repositoryRemovalCompleted( 2114 + let repositoryID, let outcome, let selectionWasRemoved): 2115 + // Aggregator entry point. Every repo-level removal 2116 + // (successful or not) drains through here so bulk batches 2117 + // fire a single terminal `.repositoriesRemoved` after the 2118 + // last target reports in. `.failure` outcomes keep the 2119 + // batch progressing past failures without removing the 2120 + // repo from state. 2121 + guard let record = state.removingRepositoryIDs[repositoryID], 2122 + var batch = state.activeRemovalBatches[record.batchID] 2123 + else { 2124 + // Orphaned completion — every sender seeds the record + 2125 + // batch before signalling, so arriving here means a bug 2126 + // (e.g. future caller skipped setup). Surface it loudly 2127 + // via `reportIssue` so tests fail and release builds emit 2128 + // a warning, and defensively clean up any state the 2129 + // absent terminal would otherwise leave hanging. 2130 + reportIssue( 2131 + """ 2132 + repositoryRemovalCompleted: no active batch for \(repositoryID). \ 2133 + This indicates an invariant violation — every confirm handler \ 2134 + must seed a batch before per-target work fires. 2135 + """ 2136 + ) 2137 + state.removingRepositoryIDs[repositoryID] = nil 2138 + // Shared cleanup for the two failure-under-orphan paths: 2139 + // clear per-worktree trackers for this repo's folder-synthetic 2140 + // worktree id so `deletingWorktreeIDs` / 2141 + // `deleteScriptWorktreeIDs` entries can't leak beyond the 2142 + // failed attempt. Only the folder-synthetic id is ever 2143 + // populated by the folder removal pipeline; narrow the 2144 + // cleanup to it so a future caller passing a git repo id 2145 + // here can't accidentally clobber in-flight worktree-delete 2146 + // trackers for sibling git worktrees. 2147 + let orphanFolderWorktreeID = Repository.folderWorktreeID( 2148 + for: URL(fileURLWithPath: repositoryID) 2149 + ) 2150 + switch outcome { 2151 + case .success: 2152 + return .send( 2153 + .repositoriesRemoved([repositoryID], selectionWasRemoved: selectionWasRemoved)) 2154 + case .failureSilent: 2155 + state.deletingWorktreeIDs.remove(orphanFolderWorktreeID) 2156 + state.deleteScriptWorktreeIDs.remove(orphanFolderWorktreeID) 2157 + return .none 2158 + case .failureWithMessage(let message): 2159 + state.deletingWorktreeIDs.remove(orphanFolderWorktreeID) 2160 + state.deleteScriptWorktreeIDs.remove(orphanFolderWorktreeID) 2161 + state.alert = messageAlert( 2162 + title: "Delete from disk failed", message: message 2163 + ) 2164 + return .none 2165 + } 2166 + } 2167 + let batchID = record.batchID 2168 + batch.pending.remove(repositoryID) 2169 + batch.selectionWasRemoved = batch.selectionWasRemoved || selectionWasRemoved 2170 + // Shared failure cleanup — drain the target from the batch 2171 + // without removing the repo from state. Clears the record 2172 + // AND the folder-synthetic per-worktree trackers — 2173 + // `deletingWorktreeIDs` / `deleteScriptWorktreeIDs` 2174 + // entries seeded by the empty-script folder branch (or the 2175 + // blocking-script run) would otherwise leave the row stuck 2176 + // in `.deleting` forever. Scoped to the synthetic folder 2177 + // worktree id because only folder dispositions ever reach 2178 + // a failure completion (`.gitRepositoryUnlink` hardcodes 2179 + // `.success` at confirm time); clearing every worktree of 2180 + // the repo would reach too far if a future caller extends 2181 + // this path to git repos. 2182 + let folderWorktreeIDForFailure: Worktree.ID? = 2183 + record.disposition.isFolder 2184 + ? Repository.folderWorktreeID(for: URL(fileURLWithPath: repositoryID)) 2185 + : nil 2186 + switch outcome { 2187 + case .success: 2188 + batch.succeeded.append(repositoryID) 2189 + // `.repositoriesRemoved` clears `removingRepositoryIDs` 2190 + // for the successful targets as part of the terminal — 2191 + // leave the record in place so the UI keeps showing the 2192 + // "removing" indicator until then. 2193 + case .failureSilent: 2194 + state.removingRepositoryIDs[repositoryID] = nil 2195 + if let folderWorktreeIDForFailure { 2196 + state.deletingWorktreeIDs.remove(folderWorktreeIDForFailure) 2197 + state.deleteScriptWorktreeIDs.remove(folderWorktreeIDForFailure) 2198 + } 2199 + batch.hasSilentFailure = true 2200 + case .failureWithMessage(let message): 2201 + state.removingRepositoryIDs[repositoryID] = nil 2202 + if let folderWorktreeIDForFailure { 2203 + state.deletingWorktreeIDs.remove(folderWorktreeIDForFailure) 2204 + state.deleteScriptWorktreeIDs.remove(folderWorktreeIDForFailure) 2205 + } 2206 + batch.failureMessagesByRepositoryID[repositoryID] = message 2207 + } 2208 + if batch.pending.isEmpty { 2209 + state.activeRemovalBatches[batchID] = nil 2210 + // Consolidated failure alert — when any target in the 2211 + // batch reported a `.failureWithMessage`, surface one 2212 + // alert listing them. Avoids parallel `.presentAlert` 2213 + // races where the last trash failure overwrites the 2214 + // others. 2215 + // 2216 + // When a `.failureSilent` target in the same batch has 2217 + // already set `state.alert` directly (blocking-script 2218 + // failure / user cancel / kind-flip), preserve the 2219 + // caller's alert and log the trash failures instead of 2220 + // clobbering. macOS only shows one alert at a time, and 2221 + // the script-failure alert carries actionable context 2222 + // (the "View Terminal" button) that the consolidated 2223 + // trash alert does not. 2224 + if !batch.failureMessagesByRepositoryID.isEmpty { 2225 + if batch.hasSilentFailure { 2226 + for (id, message) in batch.failureMessagesByRepositoryID { 2227 + let name = state.repositories[id: id]?.name ?? id 2228 + repositoriesLogger.warning( 2229 + "Trash failure for \(name) (\(id)) suppressed " 2230 + + "(silent-failure alert already showing for sibling target): \(message)" 2231 + ) 2232 + } 2233 + } else { 2234 + // Resolve names NOW (while `state.repositories` 2235 + // still has every batch member) so the alert stays 2236 + // user-recognizable even if the downstream 2237 + // `.repositoriesRemoved` → `.repositoriesLoaded` 2238 + // reloads prune an entry before the alert is read. 2239 + var namesByRepositoryID: [Repository.ID: String] = [:] 2240 + for id in batch.failureMessagesByRepositoryID.keys { 2241 + if let name = state.repositories[id: id]?.name { 2242 + namesByRepositoryID[id] = name 2243 + } 2244 + } 2245 + state.alert = consolidatedTrashFailureAlert( 2246 + failureMessagesByRepositoryID: batch.failureMessagesByRepositoryID, 2247 + namesByRepositoryID: namesByRepositoryID 2248 + ) 2249 + } 2250 + } 2251 + guard !batch.succeeded.isEmpty else { return .none } 2252 + return .send( 2253 + .repositoriesRemoved( 2254 + batch.succeeded, selectionWasRemoved: batch.selectionWasRemoved)) 2255 + } 2256 + state.activeRemovalBatches[batchID] = batch 2257 + return .none 1785 2258 1786 - case .repositoryRemoved(let repositoryID, let selectionWasRemoved): 1787 - analyticsClient.capture("repository_removed", nil) 1788 - state.removingRepositoryIDs.remove(repositoryID) 2259 + case .repositoriesRemoved(let repositoryIDs, let selectionWasRemoved): 2260 + // Bulk terminal: mutates `repositories` / `repositoryRoots` 2261 + // synchronously, emits one `.repositoriesLoaded` for 2262 + // reconciliation and a single cancellable persistence save. 2263 + // Firing once per batch (instead of once per target) removes 2264 + // the reload race. 2265 + guard !repositoryIDs.isEmpty else { return .none } 2266 + let idSet = Set(repositoryIDs) 2267 + for id in repositoryIDs { 2268 + let kind = (state.repositories[id: id]?.isGitRepository ?? true) ? "git" : "folder" 2269 + analyticsClient.capture("repository_removed", ["kind": kind]) 2270 + state.removingRepositoryIDs[id] = nil 2271 + } 1789 2272 if selectionWasRemoved { 1790 2273 state.selection = nil 1791 2274 state.shouldSelectFirstAfterReload = true 1792 2275 } 1793 2276 let selectedWorktree = state.worktree(for: state.selectedWorktreeID) 2277 + let remainingRepositories = Array(state.repositories.filter { !idSet.contains($0.id) }) 2278 + let remainingRoots = state.repositoryRoots.filter { 2279 + !idSet.contains($0.standardizedFileURL.path(percentEncoded: false)) 2280 + } 2281 + let remainingFailures = state.loadFailuresByID 2282 + .filter { !idSet.contains($0.key) } 2283 + .map { LoadFailure(rootID: $0.key, message: $0.value) } 2284 + let pathsToPersist = remainingRoots.map { 2285 + $0.standardizedFileURL.path(percentEncoded: false) 2286 + } 2287 + let removedIDs = Array(idSet) 1794 2288 return .merge( 1795 2289 .send(.delegate(.selectedWorktreeChanged(selectedWorktree))), 1796 - .run { send in 1797 - let loadedPaths = await repositoryPersistence.loadRoots() 1798 - var seen: Set<String> = [] 1799 - let rootPaths = loadedPaths.filter { seen.insert($0).inserted } 1800 - let remaining = rootPaths.filter { $0 != repositoryID } 1801 - await repositoryPersistence.saveRoots(remaining) 1802 - let roots = remaining.map { URL(fileURLWithPath: $0) } 1803 - let (repositories, failures) = await loadRepositoriesData(roots) 1804 - await send( 1805 - .repositoriesLoaded( 1806 - repositories, 1807 - failures: failures, 1808 - roots: roots, 1809 - animated: true 1810 - ) 2290 + .send( 2291 + .repositoriesLoaded( 2292 + remainingRepositories, 2293 + failures: remainingFailures, 2294 + roots: remainingRoots, 2295 + animated: true 1811 2296 ) 2297 + ), 2298 + .run { _ in 2299 + // `saveRoots` replaces the `repositoryRoots` array with 2300 + // the pruned list; `pruneRepositoryConfigs` drops the 2301 + // `repositories` dict entries (scripts / run config / 2302 + // open action) for repos that just left. Without the 2303 + // second step those entries pile up forever — 2304 + // especially visible for folder repos that users add + 2305 + // remove while exploring. 2306 + await repositoryPersistence.saveRoots(pathsToPersist) 2307 + await repositoryPersistence.pruneRepositoryConfigs(removedIDs) 1812 2308 } 1813 - .cancellable(id: CancelID.load, cancelInFlight: true) 2309 + .cancellable(id: CancelID.persistRoots, cancelInFlight: true) 1814 2310 ) 1815 2311 1816 2312 case .pinWorktree(let worktreeID): 1817 2313 // Main worktrees never appear in any sidebar bucket (the 1818 2314 // seed pass skips them), so pinning one is a no-op. 1819 2315 guard let worktree = state.worktree(for: worktreeID), 1820 - !state.isMainWorktree(worktree), 1821 - let repositoryID = state.repositoryID(containing: worktreeID) 2316 + let repositoryID = state.repositoryID(containing: worktreeID), 2317 + let repository = state.repositories[id: repositoryID] 1822 2318 else { 2319 + return .none 2320 + } 2321 + // Folder-synthetic worktrees pass `isMainWorktree` by 2322 + // geometry. Surface the deeplink-equivalent alert instead 2323 + // of silently no-op-ing for folders; for git mains the 2324 + // silent skip is still correct (main-worktree pinning is 2325 + // invalid by design). 2326 + if !repository.isGitRepository { 2327 + state.alert = folderIncompatibleAlert(action: .pin) 2328 + return .none 2329 + } 2330 + if state.isMainWorktree(worktree) { 1823 2331 return .none 1824 2332 } 1825 2333 analyticsClient.capture("worktree_pinned", nil) ··· 1839 2347 return .none 1840 2348 1841 2349 case .unpinWorktree(let worktreeID): 1842 - guard let repositoryID = state.repositoryID(containing: worktreeID) else { 2350 + guard let repositoryID = state.repositoryID(containing: worktreeID), 2351 + let repository = state.repositories[id: repositoryID] 2352 + else { 2353 + return .none 2354 + } 2355 + if !repository.isGitRepository { 2356 + state.alert = folderIncompatibleAlert(action: .unpin) 1843 2357 return .none 1844 2358 } 1845 2359 analyticsClient.capture("worktree_unpinned", nil) ··· 2157 2671 } 2158 2672 let effects: [Effect<Action>] = 2159 2673 archiveWorktreeIDs.map { .send(.archiveWorktreeConfirmed($0, repositoryID)) } 2160 - + deleteWorktreeIDs.map { .send(.deleteWorktreeConfirmed($0, repositoryID)) } 2674 + + deleteWorktreeIDs.map { .send(.deleteSidebarItemConfirmed($0, repositoryID)) } 2161 2675 guard !effects.isEmpty else { 2162 2676 return .none 2163 2677 } ··· 2500 3014 guard let period = state.autoDeleteArchivedWorktreesAfterDays else { return .none } 2501 3015 let cutoff = now.addingTimeInterval(-Double(period.rawValue) * secondsPerDay) 2502 3016 var targets: [(Worktree.ID, Repository.ID)] = [] 3017 + // Folder-synthetic archived entries can't be produced by 3018 + // any current user path (context-menu / shortcut / deeplink 3019 + // all reject folder archives). If one leaks into persisted 3020 + // state — a bug in a future archive path, a migration 3021 + // regression, or hand-edited sidebar.json — we both flag 3022 + // the invariant breach AND purge the stray entry from 3023 + // `sidebar.archivedWorktrees`, so the next reload doesn't 3024 + // re-fire `reportIssue` forever. 3025 + var strayFolderArchives: [(Worktree.ID, Repository.ID)] = [] 3026 + for archived in state.sidebar.archivedWorktrees 3027 + where Repository.isFolderWorktreeID(archived.worktreeID) { 3028 + strayFolderArchives.append((archived.worktreeID, archived.repositoryID)) 3029 + } 3030 + if !strayFolderArchives.isEmpty { 3031 + for (worktreeID, _) in strayFolderArchives { 3032 + reportIssue( 3033 + """ 3034 + Auto-delete encountered folder-synthetic archived worktree \(worktreeID) — \ 3035 + folders are not archivable. Purging the stray entry. 3036 + """ 3037 + ) 3038 + } 3039 + state.$sidebar.withLock { sidebar in 3040 + for (worktreeID, repositoryID) in strayFolderArchives { 3041 + sidebar.remove(worktree: worktreeID, in: repositoryID, from: .archived) 3042 + } 3043 + } 3044 + } 2503 3045 for archived in state.sidebar.archivedWorktrees { 2504 3046 let worktreeID = archived.worktreeID 2505 3047 guard archived.archivedAt <= cutoff else { continue } 3048 + if Repository.isFolderWorktreeID(worktreeID) { 3049 + // Already purged above — defensive skip. 3050 + continue 3051 + } 2506 3052 guard !state.deletingWorktreeIDs.contains(worktreeID), 2507 3053 !state.deleteScriptWorktreeIDs.contains(worktreeID), 2508 3054 !state.archivingWorktreeIDs.contains(worktreeID) ··· 2527 3073 repositoriesLogger.info("Auto-deleting \(targets.count) expired archived worktree(s).") 2528 3074 return .merge( 2529 3075 targets.map { worktreeID, repositoryID in 2530 - .send(.deleteWorktreeConfirmed(worktreeID, repositoryID)) 3076 + .send(.deleteSidebarItemConfirmed(worktreeID, repositoryID)) 2531 3077 } 2532 3078 ) 2533 3079 ··· 2622 3168 2623 3169 private struct WorktreesFetchResult: Sendable { 2624 3170 let root: URL 3171 + let isGitRepository: Bool 2625 3172 let worktrees: [Worktree]? 2626 3173 let errorMessage: String? 2627 3174 } ··· 2631 3178 for root in roots { 2632 3179 let gitClient = self.gitClient 2633 3180 group.addTask { 3181 + // Directory-existence check first — if the root is gone 3182 + // (user trashed it from Finder while Supacode was 3183 + // running, external tooling removed it, the volume is 3184 + // unmounted), surface a load failure so the sidebar 3185 + // shows the error row. Otherwise `gitClient.isGitRepository` 3186 + // returns `false` for the missing path and the loader 3187 + // silently synthesizes an empty folder repository, which 3188 + // hides the real problem from the user. Routed through 3189 + // the dependency so tests with fake `/tmp/...` paths 3190 + // don't trip the check — they override it explicitly. 3191 + let exists = await gitClient.rootDirectoryExists(root) 3192 + guard exists else { 3193 + return WorktreesFetchResult( 3194 + root: root, 3195 + isGitRepository: false, 3196 + worktrees: nil, 3197 + errorMessage: 3198 + "Directory not found at \(root.standardizedFileURL.path(percentEncoded: false)). " 3199 + + "It may have been moved or deleted." 3200 + ) 3201 + } 3202 + // Classify through the git client so tests can override 3203 + // without touching the filesystem — non-git folders skip 3204 + // the worktrees subprocess entirely. 3205 + let isGit = await gitClient.isGitRepository(root) 3206 + guard isGit else { 3207 + return WorktreesFetchResult( 3208 + root: root, 3209 + isGitRepository: false, 3210 + worktrees: [], 3211 + errorMessage: nil 3212 + ) 3213 + } 2634 3214 do { 2635 3215 let worktrees = try await gitClient.worktrees(root) 2636 - return WorktreesFetchResult(root: root, worktrees: worktrees, errorMessage: nil) 3216 + return WorktreesFetchResult( 3217 + root: root, 3218 + isGitRepository: true, 3219 + worktrees: worktrees, 3220 + errorMessage: nil 3221 + ) 2637 3222 } catch { 2638 3223 return WorktreesFetchResult( 2639 3224 root: root, 3225 + isGitRepository: true, 2640 3226 worktrees: nil, 2641 3227 errorMessage: error.localizedDescription 2642 3228 ) ··· 2658 3244 let normalizedRoot = root.standardizedFileURL 2659 3245 let rootID = normalizedRoot.path(percentEncoded: false) 2660 3246 guard let result = fetchResults[rootID] else { continue } 2661 - if let worktrees = result.worktrees { 2662 - let name = Repository.name(for: normalizedRoot) 3247 + let name = Repository.name(for: normalizedRoot) 3248 + if result.isGitRepository { 3249 + if let worktrees = result.worktrees { 3250 + let repository = Repository( 3251 + id: rootID, 3252 + rootURL: normalizedRoot, 3253 + name: name, 3254 + worktrees: IdentifiedArray(uniqueElements: worktrees), 3255 + isGitRepository: true 3256 + ) 3257 + loaded.append(repository) 3258 + } else { 3259 + failures.append( 3260 + LoadFailure( 3261 + rootID: rootID, 3262 + message: result.errorMessage ?? "Unknown error" 3263 + ) 3264 + ) 3265 + } 3266 + } else if let errorMessage = result.errorMessage { 3267 + // Non-git root with an error — classifier couldn't open 3268 + // the directory (missing / unmounted / unreadable). 3269 + // Route through the same `LoadFailure` pipeline git 3270 + // repos use so the sidebar shows the error row. 3271 + failures.append( 3272 + LoadFailure(rootID: rootID, message: errorMessage) 3273 + ) 3274 + } else { 3275 + // Folder repository — synthesize a single main-like worktree 3276 + // so the existing sidebar selection + terminal plumbing keeps 3277 + // working without new entity types. 3278 + let synthetic = Worktree( 3279 + id: Repository.folderWorktreeID(for: normalizedRoot), 3280 + name: name, 3281 + detail: "", 3282 + workingDirectory: normalizedRoot, 3283 + repositoryRootURL: normalizedRoot 3284 + ) 2663 3285 let repository = Repository( 2664 3286 id: rootID, 2665 3287 rootURL: normalizedRoot, 2666 3288 name: name, 2667 - worktrees: IdentifiedArray(uniqueElements: worktrees) 3289 + worktrees: IdentifiedArray(uniqueElements: [synthetic]), 3290 + isGitRepository: false 2668 3291 ) 2669 3292 loaded.append(repository) 2670 - } else { 2671 - failures.append( 2672 - LoadFailure( 2673 - rootID: rootID, 2674 - message: result.errorMessage ?? "Unknown error" 2675 - ) 2676 - ) 2677 3293 } 2678 3294 } 2679 3295 return (loaded, failures) ··· 2723 3339 let filteredWorktreeInfo = state.worktreeInfoByID.filter { 2724 3340 availableWorktreeIDs.contains($0.key) 2725 3341 } 3342 + let (filteredRemovingRepositoryIDs, filteredActiveRemovalBatches) = 3343 + prunedRemovalTrackers(state: state, availableRepoIDs: repositoryIDs) 2726 3344 let identifiedRepositories = IdentifiedArray(uniqueElements: repositories) 2727 3345 if animated { 2728 3346 withAnimation { ··· 2736 3354 2737 3355 state.archivingWorktreeIDs = filteredArchivingIDs 2738 3356 state.worktreeInfoByID = filteredWorktreeInfo 3357 + state.removingRepositoryIDs = filteredRemovingRepositoryIDs 3358 + state.activeRemovalBatches = filteredActiveRemovalBatches 2739 3359 } 2740 3360 } else { 2741 3361 state.repositories = identifiedRepositories ··· 2747 3367 state.runningScriptsByWorktreeID = filteredRunningScripts 2748 3368 state.archivingWorktreeIDs = filteredArchivingIDs 2749 3369 state.worktreeInfoByID = filteredWorktreeInfo 3370 + state.removingRepositoryIDs = filteredRemovingRepositoryIDs 3371 + state.activeRemovalBatches = filteredActiveRemovalBatches 2750 3372 } 2751 3373 // Reconcile unconditionally so the seed invariant ("every live 2752 3374 // non-main worktree has a bucket") holds after partial-failure ··· 2796 3418 return ApplyRepositoriesResult(didPruneArchivedWorktreeIDs: didPruneArchivedWorktreeIDs) 2797 3419 } 2798 3420 3421 + /// Symmetric prune for the repo-level removal trackers — every 3422 + /// other tracker in `applyRepositories` is intersected against 3423 + /// the live roster; leaving these two alone would let a 3424 + /// mid-flight removal dangle if a concurrent reload drops the 3425 + /// owning repo before the detached trash/unlink effect reports 3426 + /// completion. The prune is silent: orphan-completion handlers 3427 + /// in `.repositoryRemovalCompleted` already tolerate missing 3428 + /// records, and a `reportIssue` here would fire on legitimate 3429 + /// reload-during-removal flows (especially the synchronous 3430 + /// `.gitRepositoryUnlink` path). The symmetry itself is the 3431 + /// win — a future regression that leaves real garbage here 3432 + /// would now be cleared on the next reload instead of 3433 + /// silently piling up. 3434 + private func prunedRemovalTrackers( 3435 + state: State, 3436 + availableRepoIDs: Set<Repository.ID> 3437 + ) -> ( 3438 + removingRepositoryIDs: [Repository.ID: RepositoryRemovalRecord], 3439 + activeRemovalBatches: [BatchID: ActiveRemovalBatch] 3440 + ) { 3441 + var removing = state.removingRepositoryIDs 3442 + var batches = state.activeRemovalBatches 3443 + for droppedID in removing.keys where !availableRepoIDs.contains(droppedID) { 3444 + removing[droppedID] = nil 3445 + } 3446 + for (batchID, batch) in batches { 3447 + let surviving = batch.pending.intersection(availableRepoIDs) 3448 + guard surviving.count != batch.pending.count else { continue } 3449 + if surviving.isEmpty, batch.succeeded.isEmpty { 3450 + batches[batchID] = nil 3451 + } else { 3452 + var pruned = batch 3453 + pruned.pending = surviving 3454 + for droppedID in batch.pending.subtracting(surviving) { 3455 + pruned.failureMessagesByRepositoryID[droppedID] = nil 3456 + } 3457 + batches[batchID] = pruned 3458 + } 3459 + } 3460 + return (removing, batches) 3461 + } 3462 + 2799 3463 private func blockingScriptFailureAlert( 2800 3464 kind: BlockingScriptKind, 2801 3465 exitCode: Int, ··· 2827 3491 } 2828 3492 } 2829 3493 2830 - private func messageAlert(title: String, message: String) -> AlertState<Alert> { 2831 - AlertState { 2832 - TextState(title) 2833 - } actions: { 2834 - ButtonState(role: .cancel) { 2835 - TextState("OK") 2836 - } 2837 - } message: { 2838 - TextState(message) 2839 - } 2840 - } 2841 - 2842 - private func confirmationAlertForRepositoryRemoval( 2843 - repositoryID: Repository.ID, 2844 - state: State 2845 - ) -> AlertState<Alert>? { 2846 - guard let repository = state.repositories[id: repositoryID] else { 2847 - return nil 2848 - } 2849 - return AlertState { 2850 - TextState("Remove repository?") 2851 - } actions: { 2852 - ButtonState(role: .destructive, action: .confirmRemoveRepository(repository.id)) { 2853 - TextState("Remove repository") 2854 - } 2855 - ButtonState(role: .cancel) { 2856 - TextState("Cancel") 2857 - } 2858 - } message: { 2859 - TextState( 2860 - "This removes the repository from Supacode. " 2861 - + "Worktrees and the main repository folder stay on disk." 2862 - ) 2863 - } 2864 - } 2865 - 2866 3494 } 2867 3495 2868 3496 extension RepositoriesFeature.State { ··· 2870 3498 selection?.worktreeID 2871 3499 } 2872 3500 2873 - var effectiveSidebarSelectedRows: [WorktreeRowModel] { 2874 - let selectedRows = orderedWorktreeRows().filter { sidebarSelectedWorktreeIDs.contains($0.id) } 3501 + var effectiveSidebarSelectedRows: [SidebarItemModel] { 3502 + let selectedRows = orderedSidebarItems().filter { sidebarSelectedWorktreeIDs.contains($0.id) } 2875 3503 return selectedRows.isEmpty ? (selectedRow(for: selectedWorktreeID).map { [$0] } ?? []) : selectedRows 2876 3504 } 2877 3505 ··· 2900 3528 } 2901 3529 2902 3530 func worktreeID(byOffset offset: Int) -> Worktree.ID? { 2903 - let rows = orderedWorktreeRows(includingRepositoryIDs: expandedRepositoryIDs) 3531 + let rows = orderedSidebarItems(includingRepositoryIDs: expandedRepositoryIDs) 2904 3532 guard !rows.isEmpty else { return nil } 2905 3533 if let currentID = selectedWorktreeID, 2906 3534 let currentIndex = rows.firstIndex(where: { $0.id == currentID }) ··· 2941 3569 } 2942 3570 2943 3571 func worktreesForInfoWatcher() -> [Worktree] { 2944 - let worktrees = repositories.flatMap(\.worktrees) 3572 + // Folder repositories are non-git — skip them so the watcher 3573 + // doesn't attempt to observe HEAD / diff stats on a directory 3574 + // without a `.git` path. 3575 + let worktrees = 3576 + repositories 3577 + .filter(\.isGitRepository) 3578 + .flatMap(\.worktrees) 2945 3579 guard !isShowingArchivedWorktrees else { 2946 3580 return worktrees 2947 3581 } ··· 2966 3600 return false 2967 3601 } 2968 3602 if let repository = repositoryForWorktreeCreation(self) { 2969 - return !removingRepositoryIDs.contains(repository.id) 3603 + return removingRepositoryIDs[repository.id] == nil 2970 3604 } 2971 3605 return false 2972 3606 } ··· 3002 3636 pendingTerminalFocusWorktreeIDs.contains(worktreeID) 3003 3637 } 3004 3638 3005 - private func makePendingWorktreeRow(_ pending: PendingWorktree) -> WorktreeRowModel { 3006 - let status: WorktreeRowModel.Status = 3007 - removingRepositoryIDs.contains(pending.repositoryID) 3639 + private func makePendingSidebarItem(_ pending: PendingWorktree) -> SidebarItemModel { 3640 + let status: SidebarItemModel.Status = 3641 + removingRepositoryIDs[pending.repositoryID] != nil 3008 3642 ? .deleting(inTerminal: false) 3009 3643 : .pending 3010 - return WorktreeRowModel( 3644 + // Folders cannot have pending worktrees — creation is gated on 3645 + // `isGitRepository` before reaching `.createWorktreeStream`. 3646 + return SidebarItemModel( 3011 3647 id: pending.id, 3012 3648 repositoryID: pending.repositoryID, 3649 + kind: .git, 3013 3650 name: pending.progress.worktreeName ?? "Creating…", 3014 3651 detail: pending.progress.worktreeName ?? "", 3015 3652 info: worktreeInfo(for: pending.id), ··· 3019 3656 ) 3020 3657 } 3021 3658 3022 - private func makeWorktreeRow( 3659 + private func makeSidebarItem( 3023 3660 _ worktree: Worktree, 3024 3661 repositoryID: Repository.ID, 3662 + kind: SidebarItemModel.Kind, 3025 3663 isPinned: Bool, 3026 3664 isMainWorktree: Bool 3027 - ) -> WorktreeRowModel { 3028 - let status: WorktreeRowModel.Status = 3029 - if removingRepositoryIDs.contains(repositoryID) 3665 + ) -> SidebarItemModel { 3666 + // `deleteScriptWorktreeIDs` wins over `removingRepositoryIDs` so 3667 + // a folder delete with a blocking script shows the terminal 3668 + // indicator and stays clickable (matching the worktree flow), 3669 + // rather than being immediately masked by the repo-level 3670 + // "removing" flag that the folder pipeline sets up front to 3671 + // carry the removal intent. 3672 + let status: SidebarItemModel.Status = 3673 + if deleteScriptWorktreeIDs.contains(worktree.id) { 3674 + .deleting(inTerminal: true) 3675 + } else if removingRepositoryIDs[repositoryID] != nil 3030 3676 || deletingWorktreeIDs.contains(worktree.id) 3031 3677 { 3032 3678 .deleting(inTerminal: false) 3033 - } else if deleteScriptWorktreeIDs.contains(worktree.id) { 3034 - .deleting(inTerminal: true) 3035 3679 } else if archivingWorktreeIDs.contains(worktree.id) { 3036 3680 .archiving 3037 3681 } else { 3038 3682 .idle 3039 3683 } 3040 - return WorktreeRowModel( 3684 + return SidebarItemModel( 3041 3685 id: worktree.id, 3042 3686 repositoryID: repositoryID, 3687 + kind: kind, 3043 3688 name: worktree.name, 3044 3689 detail: worktree.detail, 3045 3690 info: worktreeInfo(for: worktree.id), ··· 3049 3694 ) 3050 3695 } 3051 3696 3052 - func selectedRow(for id: Worktree.ID?) -> WorktreeRowModel? { 3697 + func selectedRow(for id: Worktree.ID?) -> SidebarItemModel? { 3053 3698 guard let id else { return nil } 3054 3699 if isWorktreeArchived(id) { 3055 3700 return nil 3056 3701 } 3057 3702 if let pending = pendingWorktree(for: id) { 3058 - return makePendingWorktreeRow(pending) 3703 + return makePendingSidebarItem(pending) 3059 3704 } 3060 3705 for repository in repositories { 3061 3706 if let worktree = repository.worktrees[id: id] { 3062 - return makeWorktreeRow( 3707 + return makeSidebarItem( 3063 3708 worktree, 3064 3709 repositoryID: repository.id, 3710 + kind: repository.isGitRepository ? .git : .folder, 3065 3711 isPinned: isWorktreePinned(worktree), 3066 3712 isMainWorktree: isMainWorktree(worktree) 3067 3713 ) ··· 3198 3844 if case .confirmArchiveWorktrees(let targets)? = button.action.action { 3199 3845 return .confirmArchiveWorktrees(targets) 3200 3846 } 3201 - if case .confirmDeleteWorktree(let worktreeID, let repositoryID)? = button.action.action { 3202 - return .confirmDeleteWorktree(worktreeID, repositoryID) 3203 - } 3204 - if case .confirmDeleteWorktrees(let targets)? = button.action.action { 3205 - return .confirmDeleteWorktrees(targets) 3847 + if case .confirmDeleteSidebarItems(let targets, let disposition)? = button.action.action { 3848 + return .confirmDeleteSidebarItems(targets, disposition: disposition) 3206 3849 } 3207 3850 } 3208 3851 return nil 3209 3852 } 3210 3853 3211 3854 func isRemovingRepository(_ repository: Repository) -> Bool { 3212 - removingRepositoryIDs.contains(repository.id) 3855 + guard removingRepositoryIDs[repository.id] != nil else { return false } 3856 + // While a folder's delete script is running, don't treat the 3857 + // repo as "removing" — the sidebar row must stay clickable so 3858 + // the user can view the script terminal and, on failure, retry 3859 + // or cancel. 3860 + let folderWorktreeID = Repository.folderWorktreeID(for: repository.rootURL) 3861 + if !repository.isGitRepository, deleteScriptWorktreeIDs.contains(folderWorktreeID) { 3862 + return false 3863 + } 3864 + return true 3213 3865 } 3214 3866 3215 - func worktreeRowSections(in repository: Repository) -> WorktreeRowSections { 3867 + func sidebarItemSections(in repository: Repository) -> SidebarItemSections { 3868 + let kind: SidebarItemModel.Kind = repository.isGitRepository ? .git : .folder 3216 3869 let mainWorktree = repository.worktrees.first(where: { isMainWorktree($0) }) 3217 3870 let pinnedWorktrees = orderedPinnedWorktrees(in: repository) 3218 3871 let unpinnedWorktrees = orderedUnpinnedWorktrees(in: repository) 3219 3872 let pendingEntries = pendingWorktrees.filter { $0.repositoryID == repository.id } 3220 - let mainRow: WorktreeRowModel? = 3873 + let mainRow: SidebarItemModel? = 3221 3874 if let mainWorktree, !isWorktreeArchived(mainWorktree.id) { 3222 - makeWorktreeRow( 3875 + makeSidebarItem( 3223 3876 mainWorktree, 3224 3877 repositoryID: repository.id, 3878 + kind: kind, 3225 3879 isPinned: false, 3226 3880 isMainWorktree: true 3227 3881 ) 3228 3882 } else { 3229 3883 nil 3230 3884 } 3231 - var pinnedRows: [WorktreeRowModel] = [] 3885 + var pinnedRows: [SidebarItemModel] = [] 3232 3886 for worktree in pinnedWorktrees { 3233 3887 pinnedRows.append( 3234 - makeWorktreeRow( 3888 + makeSidebarItem( 3235 3889 worktree, 3236 3890 repositoryID: repository.id, 3891 + kind: kind, 3237 3892 isPinned: true, 3238 3893 isMainWorktree: false 3239 3894 ) 3240 3895 ) 3241 3896 } 3242 - var pendingRows: [WorktreeRowModel] = [] 3897 + var pendingRows: [SidebarItemModel] = [] 3243 3898 for pending in pendingEntries { 3244 - pendingRows.append(makePendingWorktreeRow(pending)) 3899 + pendingRows.append(makePendingSidebarItem(pending)) 3245 3900 } 3246 - var unpinnedRows: [WorktreeRowModel] = [] 3901 + var unpinnedRows: [SidebarItemModel] = [] 3247 3902 for worktree in unpinnedWorktrees { 3248 3903 unpinnedRows.append( 3249 - makeWorktreeRow( 3904 + makeSidebarItem( 3250 3905 worktree, 3251 3906 repositoryID: repository.id, 3907 + kind: kind, 3252 3908 isPinned: false, 3253 3909 isMainWorktree: false 3254 3910 ) ··· 3264 3920 !unpinnedIDSet.contains(worktree.id) 3265 3921 else { continue } 3266 3922 unpinnedRows.append( 3267 - makeWorktreeRow( 3923 + makeSidebarItem( 3268 3924 worktree, 3269 3925 repositoryID: repository.id, 3926 + kind: kind, 3270 3927 isPinned: false, 3271 3928 isMainWorktree: false 3272 3929 ) 3273 3930 ) 3274 3931 } 3275 - return WorktreeRowSections( 3932 + return SidebarItemSections( 3276 3933 main: mainRow, 3277 3934 pinned: pinnedRows, 3278 3935 pending: pendingRows, ··· 3280 3937 ) 3281 3938 } 3282 3939 3283 - func worktreeRows(in repository: Repository) -> [WorktreeRowModel] { 3284 - let sections = worktreeRowSections(in: repository) 3940 + func sidebarItems(in repository: Repository) -> [SidebarItemModel] { 3941 + let sections = sidebarItemSections(in: repository) 3285 3942 return sections.allRows 3286 3943 } 3287 3944 3288 - func orderedWorktreeRows() -> [WorktreeRowModel] { 3289 - orderedWorktreeRows(includingRepositoryIDs: Set(repositories.map(\.id))) 3945 + func orderedSidebarItems() -> [SidebarItemModel] { 3946 + orderedSidebarItems(includingRepositoryIDs: Set(repositories.map(\.id))) 3290 3947 } 3291 3948 3292 - func orderedWorktreeRows(includingRepositoryIDs: Set<Repository.ID>) -> [WorktreeRowModel] { 3949 + func orderedSidebarItems(includingRepositoryIDs: Set<Repository.ID>) -> [SidebarItemModel] { 3293 3950 let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) 3294 3951 return orderedRepositoryIDs() 3295 3952 .filter { includingRepositoryIDs.contains($0) } 3296 3953 .compactMap { repositoriesByID[$0] } 3297 - .flatMap { worktreeRows(in: $0) } 3954 + .flatMap { sidebarItems(in: $0) } 3298 3955 } 3299 3956 } 3300 3957 3301 - struct WorktreeRowSections { 3302 - let main: WorktreeRowModel? 3303 - let pinned: [WorktreeRowModel] 3304 - let pending: [WorktreeRowModel] 3305 - let unpinned: [WorktreeRowModel] 3958 + struct SidebarItemSections { 3959 + let main: SidebarItemModel? 3960 + let pinned: [SidebarItemModel] 3961 + let pending: [SidebarItemModel] 3962 + let unpinned: [SidebarItemModel] 3306 3963 3307 - var allRows: [WorktreeRowModel] { 3308 - var rows: [WorktreeRowModel] = [] 3964 + var allRows: [SidebarItemModel] { 3965 + var rows: [SidebarItemModel] = [] 3309 3966 if let main { 3310 3967 rows.append(main) 3311 3968 } ··· 3644 4301 return .send(.delegate(.selectedWorktreeChanged(nil))) 3645 4302 } 3646 4303 3647 - let orderedRows = state.orderedWorktreeRows() 4304 + let orderedRows = state.orderedSidebarItems() 3648 4305 let orderedWorktreeIDs = orderedRows.map(\.id) 3649 4306 let allWorktreeIDs = Set(orderedWorktreeIDs) 3650 4307 let requestedWorktreeIDs = Set(selections.compactMap(\.worktreeID)) ··· 3702 4359 private func repositoryForWorktreeCreation( 3703 4360 _ state: RepositoriesFeature.State 3704 4361 ) -> Repository? { 4362 + // Only git repositories can host new worktrees — folders are 4363 + // filtered out so the "New Worktree" hotkey / palette entry 4364 + // resolves to a sibling git repo (or nothing) when the current 4365 + // selection lives in a folder. 3705 4366 if let selectedWorktreeID = state.selectedWorktreeID { 3706 - if let pending = state.pendingWorktree(for: selectedWorktreeID) { 3707 - return state.repositories[id: pending.repositoryID] 4367 + if let pending = state.pendingWorktree(for: selectedWorktreeID), 4368 + let pendingRepo = state.repositories[id: pending.repositoryID], 4369 + pendingRepo.isGitRepository 4370 + { 4371 + return pendingRepo 3708 4372 } 3709 4373 for repository in state.repositories 3710 - where repository.worktrees[id: selectedWorktreeID] != nil { 4374 + where repository.isGitRepository && repository.worktrees[id: selectedWorktreeID] != nil { 3711 4375 return repository 3712 4376 } 3713 4377 } 3714 - if state.repositories.count == 1 { 3715 - return state.repositories.first 4378 + let gitRepositories = state.repositories.filter(\.isGitRepository) 4379 + if gitRepositories.count == 1 { 4380 + return gitRepositories.first 3716 4381 } 3717 4382 return nil 3718 4383 } ··· 3839 4504 copy.buckets[.unpinned] = unpinned 3840 4505 } 3841 4506 rebuilt[repoID] = copy 4507 + } 4508 + 4509 + // Seed a default (empty) section for every live repository that 4510 + // doesn't yet have a `sidebar.sections` entry. Without this, a 4511 + // brand-new repo (git or folder) only surfaces through the 4512 + // `orderedRepositoryRoots()` fallback path and SwiftUI's List 4513 + // diffing can miss the insertion until the next reconcile pass. 4514 + // 4515 + // Folders intentionally keep an empty section — they have no 4516 + // pin / unpin / archive buckets — so the entry stays trivial. A 4517 + // user re-`git init`-ing a folder would have the section ready 4518 + // to accept curated bucket entries without a follow-up reconcile. 4519 + for repository in state.repositories where rebuilt[repository.id] == nil { 4520 + rebuilt[repository.id] = SidebarState.Section() 3842 4521 } 3843 4522 3844 4523 preserveOrphanSections(
+9 -2
supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift
··· 30 30 let deleteWorktreeAction: (() -> Void)? = { 31 31 guard !selectedTargets.isEmpty else { return nil } 32 32 return { 33 - store.send(.requestDeleteWorktrees(selectedTargets)) 33 + store.send(.requestDeleteSidebarItems(selectedTargets)) 34 34 } 35 35 }() 36 36 let confirmWorktreeAction: (() -> Void)? = { ··· 58 58 store.send(.unarchiveWorktree(worktree.id)) 59 59 }, 60 60 onDelete: { 61 - store.send(.requestDeleteWorktree(worktree.id, group.repository.id)) 61 + store.send( 62 + .requestDeleteSidebarItems([ 63 + RepositoriesFeature.DeleteWorktreeTarget( 64 + worktreeID: worktree.id, 65 + repositoryID: group.repository.id 66 + ) 67 + ]) 68 + ) 62 69 } 63 70 ) 64 71 .tag(worktree.id)
+4 -4
supacode/Features/Repositories/Views/EmptyStateView.swift
··· 13 13 Image(systemName: "tray") 14 14 .font(.title2) 15 15 .accessibilityHidden(true) 16 - Text("Open a git repository") 16 + Text("Open a repository or folder") 17 17 .font(.headline) 18 18 Text( 19 19 "Press \(openRepo?.display ?? AppShortcuts.openRepository.display) " 20 - + "or click Open Repository to choose a repository." 20 + + "or click Open Repository or Folder to choose one." 21 21 ) 22 22 .font(.subheadline) 23 23 .foregroundStyle(.secondary) 24 - Button("Open Repository...") { 24 + Button("Open Repository or Folder...") { 25 25 store.send(.setOpenPanelPresented(true)) 26 26 } 27 27 .appKeyboardShortcut(openRepo) 28 - .help("Open Repository (\(openRepo?.display ?? "none"))") 28 + .help("Open Repository or Folder (\(openRepo?.display ?? "none"))") 29 29 } 30 30 .frame(maxWidth: .infinity, maxHeight: .infinity) 31 31 .background(Color(nsColor: .windowBackgroundColor))
+48 -9
supacode/Features/Repositories/Views/SidebarListView.swift
··· 1 + import AppKit 1 2 import ComposableArchitecture 3 + import Sharing 4 + import SupacodeSettingsShared 2 5 import SwiftUI 3 6 4 7 struct SidebarListView: View { ··· 9 12 var body: some View { 10 13 let state = store.state 11 14 let expandedRepoIDs = state.expandedRepositoryIDs 12 - let hotkeyRows = state.orderedWorktreeRows(includingRepositoryIDs: expandedRepoIDs) 15 + let hotkeyRows = state.orderedSidebarItems(includingRepositoryIDs: expandedRepoIDs) 13 16 let orderedRoots = state.orderedRepositoryRoots() 14 17 let selectedWorktreeIDs = state.sidebarSelectedWorktreeIDs 15 18 let currentSelections = state.sidebarSelections ··· 29 32 SidebarPlaceholderView() 30 33 } else if orderedRoots.isEmpty { 31 34 ForEach(store.repositories) { repository in 32 - SidebarRepositorySectionView( 35 + SidebarRootView( 33 36 repository: repository, 34 37 hotkeyRows: hotkeyRows, 35 38 selectedWorktreeIDs: selectedWorktreeIDs, ··· 46 49 store: store 47 50 ) 48 51 } else if let repository = repositoriesByID[row.repositoryID] { 49 - SidebarRepositorySectionView( 52 + SidebarRootView( 50 53 repository: repository, 51 54 hotkeyRows: hotkeyRows, 52 55 selectedWorktreeIDs: selectedWorktreeIDs, ··· 120 123 } 121 124 } 122 125 123 - private struct SidebarRepositorySectionView: View { 126 + private struct SidebarRootView: View { 127 + let repository: Repository 128 + let hotkeyRows: [SidebarItemModel] 129 + let selectedWorktreeIDs: Set<Worktree.ID> 130 + @Bindable var store: StoreOf<RepositoriesFeature> 131 + let terminalManager: WorktreeTerminalManager 132 + 133 + var body: some View { 134 + if repository.isGitRepository { 135 + SidebarSectionView( 136 + repository: repository, 137 + hotkeyRows: hotkeyRows, 138 + selectedWorktreeIDs: selectedWorktreeIDs, 139 + store: store, 140 + terminalManager: terminalManager 141 + ) 142 + } else { 143 + // Folder repos render a single flat row so the outer 144 + // `ForEach(sidebarRootRows).onMove` can reorder them alongside 145 + // git sections. `SidebarItemsView`'s nested 146 + // ForEach-of-groups-of-rows would hide the folder from the 147 + // outer `.onMove`, breaking sidebar-wide drag. 148 + Section { 149 + SidebarFolderRow( 150 + repository: repository, 151 + selectedWorktreeIDs: selectedWorktreeIDs, 152 + store: store, 153 + terminalManager: terminalManager 154 + ) 155 + } header: { 156 + EmptyView() 157 + } 158 + } 159 + } 160 + } 161 + 162 + private struct SidebarSectionView: View { 124 163 let repository: Repository 125 - let hotkeyRows: [WorktreeRowModel] 164 + let hotkeyRows: [SidebarItemModel] 126 165 let selectedWorktreeIDs: Set<Worktree.ID> 127 166 @Bindable var store: StoreOf<RepositoriesFeature> 128 167 let terminalManager: WorktreeTerminalManager 129 168 var body: some View { 130 169 let isRemovingRepository = store.state.isRemovingRepository(repository) 131 170 Section(isExpanded: repositoryExpansionBinding) { 132 - WorktreeRowsView( 171 + SidebarItemsView( 133 172 repository: repository, 134 173 hotkeyRows: hotkeyRows, 135 174 selectedWorktreeIDs: selectedWorktreeIDs, ··· 143 182 ) 144 183 } 145 184 .sectionActions { 146 - SidebarRepositorySectionActionsView( 185 + SidebarSectionActionsView( 147 186 repositoryID: repository.id, 148 187 isRemovingRepository: isRemovingRepository, 149 188 store: store ··· 161 200 } 162 201 } 163 202 164 - private struct SidebarRepositorySectionActionsView: View { 203 + private struct SidebarSectionActionsView: View { 165 204 let repositoryID: Repository.ID 166 205 let isRemovingRepository: Bool 167 206 let store: StoreOf<RepositoriesFeature> ··· 174 213 .help("Repository Settings") 175 214 Divider() 176 215 Button("Remove Repository…", systemImage: "folder.badge.minus", role: .destructive) { 177 - store.send(.requestRemoveRepository(repositoryID)) 216 + store.send(.requestDeleteRepository(repositoryID)) 178 217 } 179 218 .help("Remove Repository") 180 219 .disabled(isRemovingRepository)
+6 -10
supacode/Features/Repositories/Views/SidebarView.swift
··· 10 10 11 11 var body: some View { 12 12 let state = store.state 13 - let visibleHotkeyRows = state.orderedWorktreeRows(includingRepositoryIDs: state.expandedRepositoryIDs) 13 + let visibleHotkeyRows = state.orderedSidebarItems(includingRepositoryIDs: state.expandedRepositoryIDs) 14 14 let effectiveSelectedRows = state.effectiveSidebarSelectedRows 15 15 let confirmWorktreeAction = makeConfirmWorktreeAction(state: state) 16 16 let archiveWorktreeAction = makeArchiveWorktreeAction(rows: effectiveSelectedRows) ··· 28 28 } label: { 29 29 Image(systemName: "folder.badge.plus") 30 30 .offset(y: -1) 31 - .accessibilityLabel("Add Repository") 31 + .accessibilityLabel("Add Repository or Folder") 32 32 } 33 - .help("Add Repository (\(openRepo?.display ?? "none"))") 33 + .help("Add Repository or Folder (\(openRepo?.display ?? "none"))") 34 34 } 35 35 } 36 36 .focusedSceneValue(\.confirmWorktreeAction, confirmWorktreeAction) ··· 49 49 } 50 50 51 51 private func makeArchiveWorktreeAction( 52 - rows: [WorktreeRowModel] 52 + rows: [SidebarItemModel] 53 53 ) -> (() -> Void)? { 54 54 let targets = 55 55 rows ··· 71 71 } 72 72 73 73 private func makeDeleteWorktreeAction( 74 - rows: [WorktreeRowModel] 74 + rows: [SidebarItemModel] 75 75 ) -> (() -> Void)? { 76 76 let targets = 77 77 rows ··· 84 84 } 85 85 guard !targets.isEmpty else { return nil } 86 86 return { 87 - if targets.count == 1, let target = targets.first { 88 - store.send(.requestDeleteWorktree(target.worktreeID, target.repositoryID)) 89 - } else { 90 - store.send(.requestDeleteWorktrees(targets)) 91 - } 87 + store.send(.requestDeleteSidebarItems(targets)) 92 88 } 93 89 } 94 90 }
+99 -29
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 117 117 repositories.selectedRow(for: worktreeID).map { 118 118 MultiSelectedWorktreeSummary( 119 119 id: $0.id, 120 + repositoryID: $0.repositoryID, 121 + kind: $0.kind, 120 122 name: $0.name, 121 123 repositoryName: repositories.repositoryName(for: $0.repositoryID) 122 124 ) ··· 432 434 } 433 435 434 436 private func loadingInfo( 435 - for selectedRow: WorktreeRowModel?, 437 + for selectedRow: SidebarItemModel?, 436 438 selectedWorktreeID: Worktree.ID?, 437 439 repositories: RepositoriesFeature.State 438 440 ) -> WorktreeLoadingInfo? { ··· 605 607 606 608 private struct MultiSelectedWorktreeSummary: Identifiable { 607 609 let id: Worktree.ID 610 + let repositoryID: Repository.ID 611 + let kind: SidebarItemModel.Kind 608 612 let name: String 609 613 let repositoryName: String? 610 614 } ··· 621 625 622 626 private let visibleRowsLimit = 8 623 627 628 + private var worktreeRows: [MultiSelectedWorktreeSummary] { 629 + rows.filter { $0.kind == .git } 630 + } 631 + 632 + private var folderRows: [MultiSelectedWorktreeSummary] { 633 + rows.filter { $0.kind == .folder } 634 + } 635 + 636 + private var isMixedKindSelection: Bool { 637 + !worktreeRows.isEmpty && !folderRows.isEmpty 638 + } 639 + 624 640 var body: some View { 625 641 let archiveShortcut = KeyboardShortcut(.delete, modifiers: .command).display 626 642 let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display 627 - VStack(alignment: .leading, spacing: 16) { 628 - Text("\(rows.count) worktrees selected") 643 + VStack(alignment: .leading, spacing: 20) { 644 + Text("\(rows.count) items selected") 629 645 .font(.title3) 630 - VStack(alignment: .leading, spacing: 8) { 631 - ForEach(Array(rows.prefix(visibleRowsLimit))) { row in 632 - HStack(alignment: .firstTextBaseline, spacing: 8) { 633 - Text(row.name) 646 + 647 + if !worktreeRows.isEmpty { 648 + selectionSection( 649 + title: "Worktrees (\(worktreeRows.count))", 650 + rows: worktreeRows, 651 + actions: isMixedKindSelection 652 + ? [] 653 + : [ 654 + "Archive selected (\(archiveShortcut))", 655 + "Delete selected (\(deleteShortcut))", 656 + "Right-click any selected worktree to apply actions to all selected worktrees.", 657 + ] 658 + ) 659 + } 660 + 661 + if !folderRows.isEmpty { 662 + selectionSection( 663 + title: "Folders (\(folderRows.count))", 664 + rows: folderRows, 665 + actions: isMixedKindSelection 666 + ? [] 667 + : [ 668 + "Remove selected from Supacode (\(deleteShortcut))", 669 + "Right-click any selected folder to remove them all from Supacode.", 670 + ] 671 + ) 672 + } 673 + 674 + if isMixedKindSelection { 675 + VStack(alignment: .leading, spacing: 6) { 676 + Label("No bulk action available", systemImage: "exclamationmark.triangle") 677 + .font(.headline) 678 + Text( 679 + "Worktrees and folders don't share bulk actions. Deselect " 680 + + "one kind to archive/delete worktrees or remove folders." 681 + ) 682 + .font(.caption) 683 + .foregroundStyle(.secondary) 684 + } 685 + } 686 + 687 + Spacer(minLength: 0) 688 + } 689 + .padding(20) 690 + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 691 + } 692 + 693 + @ViewBuilder 694 + private func selectionSection( 695 + title: String, 696 + rows: [MultiSelectedWorktreeSummary], 697 + actions: [String] 698 + ) -> some View { 699 + VStack(alignment: .leading, spacing: 8) { 700 + Text(title) 701 + .font(.headline) 702 + ForEach(Array(rows.prefix(visibleRowsLimit))) { row in 703 + HStack(alignment: .firstTextBaseline, spacing: 8) { 704 + Text(row.name) 705 + .lineLimit(1) 706 + if let repositoryName = row.repositoryName, row.kind == .git { 707 + Text(repositoryName) 708 + .foregroundStyle(.secondary) 634 709 .lineLimit(1) 635 - if let repositoryName = row.repositoryName { 636 - Text(repositoryName) 637 - .foregroundStyle(.secondary) 638 - .lineLimit(1) 639 - } 640 710 } 641 - .font(.body) 642 711 } 643 - if rows.count > visibleRowsLimit { 644 - Text("+\(rows.count - visibleRowsLimit) more") 645 - .font(.caption) 712 + .font(.body) 713 + } 714 + if rows.count > visibleRowsLimit { 715 + Text("+\(rows.count - visibleRowsLimit) more") 716 + .font(.caption) 717 + .foregroundStyle(.secondary) 718 + } 719 + if !actions.isEmpty { 720 + VStack(alignment: .leading, spacing: 4) { 721 + Text("Available actions") 722 + .font(.subheadline) 646 723 .foregroundStyle(.secondary) 724 + ForEach(actions, id: \.self) { action in 725 + Text(action) 726 + } 647 727 } 728 + .font(.caption) 729 + .foregroundStyle(.secondary) 730 + .padding(.top, 4) 648 731 } 649 - Divider() 650 - VStack(alignment: .leading, spacing: 6) { 651 - Text("Available actions") 652 - .font(.headline) 653 - Text("Archive selected (\(archiveShortcut))") 654 - Text("Delete selected (\(deleteShortcut))") 655 - Text("Right-click any selected worktree to apply actions to all selected worktrees.") 656 - } 657 - .font(.caption) 658 - .foregroundStyle(.secondary) 659 - Spacer(minLength: 0) 660 732 } 661 - .padding(20) 662 - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 663 733 } 664 734 } 665 735
+29 -6
supacode/Features/Repositories/Views/WorktreeRow.swift supacode/Features/Repositories/Views/SidebarItemView.swift
··· 1 1 import SupacodeSettingsShared 2 2 import SwiftUI 3 3 4 - struct WorktreeRow: View { 4 + struct SidebarItemView: View { 5 + let kind: SidebarItemModel.Kind 5 6 let name: String 6 7 let subtitle: String? 7 8 let worktreeColor: WorktreeColor ··· 57 58 } 58 59 59 60 init( 60 - row: WorktreeRowModel, 61 + row: SidebarItemModel, 61 62 displayMode: WorktreeRowDisplayMode, 62 63 hideSubtitle: Bool, 63 64 hideSubtitleOnMatch: Bool, ··· 68 69 notifications: [WorktreeTerminalNotification], 69 70 shortcutHint: String? 70 71 ) { 72 + self.kind = row.kind 71 73 self.isArchiving = row.isArchiving 72 74 self.isDeleting = row.isDeleting 73 75 self.isPending = row.isPending ··· 82 84 // Worktree color. 83 85 self.worktreeColor = 84 86 if row.isMainWorktree { .main } else if row.isPinned { .pinned } else { .default } 87 + 88 + // Folders have no branch / no PR — show the folder name alone, 89 + // ignore display-mode and PR computation entirely. 90 + if row.kind == .folder { 91 + self.name = row.name 92 + self.subtitle = nil 93 + self.pullRequestBadgeText = nil 94 + self.gitIconName = "folder" 95 + self.gitIconColor = AnyShapeStyle(.secondary) 96 + self.checkBadgeState = nil 97 + return 98 + } 85 99 86 100 // Title and subtitle based on display mode. 87 101 let branchName = row.name ··· 150 164 } 151 165 } 152 166 153 - private static func worktreeName(for row: WorktreeRowModel) -> String { 167 + private static func worktreeName(for row: SidebarItemModel) -> String { 154 168 guard !row.isMainWorktree else { return "Default" } 155 169 if row.id.contains("/") { 156 170 let pathName = URL(fileURLWithPath: row.id).lastPathComponent ··· 185 199 } 186 200 } icon: { 187 201 IconView( 202 + kind: kind, 188 203 isArchiving: isArchiving, 189 204 isDeleting: isDeleting, 190 205 isPending: isPending, ··· 204 219 private struct TitleView: View { 205 220 let name: String 206 221 let subtitle: String? 207 - let worktreeColor: WorktreeRow.WorktreeColor 222 + let worktreeColor: SidebarItemView.WorktreeColor 208 223 let isBusy: Bool 209 224 @Environment(\.backgroundProminence) private var backgroundProminence 210 225 ··· 236 251 // MARK: - Icon. 237 252 238 253 private struct IconView: View { 254 + let kind: SidebarItemModel.Kind 239 255 let isArchiving: Bool 240 256 let isDeleting: Bool 241 257 let isPending: Bool 242 258 let gitIconName: String 243 259 let gitIconColor: AnyShapeStyle 244 - let checkBadgeState: WorktreeRow.CheckBadgeState? 260 + let checkBadgeState: SidebarItemView.CheckBadgeState? 245 261 @Environment(\.backgroundProminence) private var backgroundProminence 246 262 247 263 private var isEmphasized: Bool { ··· 263 279 return gitIconColor 264 280 } 265 281 282 + // Folder idle rendering uses the SF Symbol "folder"; busy-state 283 + // overrides (pending/archiving/deleting) already use system images. 266 284 private var isSystemImage: Bool { 267 - isPending || isArchiving || isDeleting 285 + isPending || isArchiving || isDeleting || kind == .folder 286 + } 287 + 288 + private var isIdleFolder: Bool { 289 + kind == .folder && !isPending && !isArchiving && !isDeleting 268 290 } 269 291 270 292 private var accessibilityLabel: String? { ··· 278 300 Group { 279 301 if isSystemImage { 280 302 Image(systemName: resolvedName) 303 + .fontWeight(.semibold) 281 304 } else { 282 305 Image(resolvedName) 283 306 .renderingMode(.template)
+134 -45
supacode/Features/Repositories/Views/WorktreeRowsView.swift supacode/Features/Repositories/Views/SidebarItemsView.swift
··· 6 6 7 7 private nonisolated let notificationLogger = SupaLogger("Notifications") 8 8 9 - struct WorktreeRowsView: View { 9 + struct SidebarItemsView: View { 10 10 private struct GroupConfiguration: Identifiable { 11 11 let id: String 12 - let rows: [WorktreeRowModel] 12 + let rows: [SidebarItemModel] 13 13 let hideSubtitle: Bool 14 - let moveBehavior: WorktreeRowGroupView.MoveBehavior 14 + let moveBehavior: SidebarItemGroupView.MoveBehavior 15 15 } 16 16 17 17 let repository: Repository 18 - let hotkeyRows: [WorktreeRowModel] 18 + let hotkeyRows: [SidebarItemModel] 19 19 let selectedWorktreeIDs: Set<Worktree.ID> 20 20 @Bindable var store: StoreOf<RepositoriesFeature> 21 21 let terminalManager: WorktreeTerminalManager ··· 24 24 25 25 var body: some View { 26 26 let state = store.state 27 - let sections = state.worktreeRowSections(in: repository) 27 + let sections = state.sidebarItemSections(in: repository) 28 28 let isSoleDefaultWorktree = sections.allRows.count == 1 && sections.main != nil 29 29 let isRepositoryRemoving = state.isRemovingRepository(repository) 30 30 let showShortcutHints = commandKeyObserver.isPressed ··· 60 60 ] 61 61 62 62 ForEach(groupConfigurations) { groupConfiguration in 63 - WorktreeRowGroupView( 63 + SidebarItemGroupView( 64 64 rows: groupConfiguration.rows, 65 65 selectedWorktreeIDs: selectedWorktreeIDs, 66 66 store: store, ··· 76 76 77 77 } 78 78 79 - private struct WorktreeRowGroupView: View { 79 + private struct SidebarItemGroupView: View { 80 80 enum MoveBehavior: Hashable { 81 81 case disabled 82 82 case pinned(Repository.ID) 83 83 case unpinned(Repository.ID) 84 84 } 85 85 86 - let rows: [WorktreeRowModel] 86 + let rows: [SidebarItemModel] 87 87 let selectedWorktreeIDs: Set<Worktree.ID> 88 88 @Bindable var store: StoreOf<RepositoriesFeature> 89 89 let terminalManager: WorktreeTerminalManager ··· 94 94 let shortcutIndexByID: [Worktree.ID: Int] 95 95 96 96 var body: some View { 97 - ForEach(rows) { row in 98 - WorktreeRowContainer( 99 - row: row, 100 - store: store, 101 - terminalManager: terminalManager, 102 - selectedWorktreeIDs: selectedWorktreeIDs, 103 - draggingWorktreeIDs: $draggingWorktreeIDs, 104 - isRepositoryRemoving: isRepositoryRemoving, 105 - hideSubtitle: hideSubtitle, 106 - moveDisabled: moveDisabled(for: row), 107 - shortcutHint: shortcutHint(for: shortcutIndexByID[row.id]) 108 - ) 97 + // Only attach `.onMove` when the group actually participates in 98 + // intra-section reorder. A no-op `onMove` on a single-row group 99 + // (e.g. the folder row or a repo's main worktree) still gets 100 + // picked up by SwiftUI's sidebar List as a drag target and 101 + // steals the repo-level reorder gesture, so the enclosing 102 + // section becomes un-draggable. 103 + switch moveBehavior { 104 + case .disabled: 105 + ForEach(rows) { row in rowContainer(for: row) } 106 + case .pinned, .unpinned: 107 + ForEach(rows) { row in rowContainer(for: row) } 108 + .onMove(perform: moveRows) 109 109 } 110 - .onMove(perform: moveRows) 111 110 } 112 111 113 - private func moveDisabled(for row: WorktreeRowModel) -> Bool { 112 + @ViewBuilder 113 + private func rowContainer(for row: SidebarItemModel) -> some View { 114 + SidebarItemContainer( 115 + row: row, 116 + store: store, 117 + terminalManager: terminalManager, 118 + selectedWorktreeIDs: selectedWorktreeIDs, 119 + draggingWorktreeIDs: $draggingWorktreeIDs, 120 + isRepositoryRemoving: isRepositoryRemoving, 121 + hideSubtitle: hideSubtitle, 122 + moveDisabled: moveDisabled(for: row), 123 + shortcutHint: shortcutHint(for: shortcutIndexByID[row.id]) 124 + ) 125 + } 126 + 127 + private func moveDisabled(for row: SidebarItemModel) -> Bool { 114 128 switch moveBehavior { 115 129 case .disabled: 116 130 true ··· 141 155 142 156 // MARK: - Row container. 143 157 144 - private struct WorktreeRowContainer: View { 145 - let row: WorktreeRowModel 158 + private struct SidebarItemContainer: View { 159 + let row: SidebarItemModel 146 160 @Bindable var store: StoreOf<RepositoriesFeature> 147 161 let terminalManager: WorktreeTerminalManager 148 162 let selectedWorktreeIDs: Set<Worktree.ID> ··· 156 170 @Environment(\.scriptsByID) private var scriptsByID 157 171 158 172 var body: some View { 159 - WorktreeRow( 173 + SidebarItemView( 160 174 row: row, 161 175 displayMode: displayMode, 162 176 hideSubtitle: hideSubtitle, ··· 184 198 .moveDisabled(moveDisabled) 185 199 .contextMenu { 186 200 if row.isRemovable, let worktree = store.state.worktree(for: row.id), !isRepositoryRemoving { 187 - WorktreeContextMenu( 201 + SidebarItemContextMenu( 188 202 worktree: worktree, 189 203 row: row, 190 204 store: store, ··· 217 231 218 232 } 219 233 234 + // MARK: - Folder row. 235 + 236 + /// Folder repositories render exactly one row (the synthesized main 237 + /// item) and must sit as a *direct* child of the outer 238 + /// `ForEach(sidebarRootRows)` in `SidebarListView` — otherwise the 239 + /// enclosing `.onMove` can't route repo-level drags to the folder. 240 + /// Bypassing `SidebarItemsView`'s nested ForEach-of-groups keeps the 241 + /// folder row flat, matching the `SidebarFailedRepositoryRow` 242 + /// pattern that already reorders correctly. 243 + struct SidebarFolderRow: View { 244 + let repository: Repository 245 + let selectedWorktreeIDs: Set<Worktree.ID> 246 + @Bindable var store: StoreOf<RepositoriesFeature> 247 + let terminalManager: WorktreeTerminalManager 248 + @State private var draggingWorktreeIDs: Set<Worktree.ID> = [] 249 + 250 + var body: some View { 251 + let state = store.state 252 + let isRepositoryRemoving = state.isRemovingRepository(repository) 253 + if let row = state.sidebarItemSections(in: repository).main { 254 + SidebarItemContainer( 255 + row: row, 256 + store: store, 257 + terminalManager: terminalManager, 258 + selectedWorktreeIDs: selectedWorktreeIDs, 259 + draggingWorktreeIDs: $draggingWorktreeIDs, 260 + isRepositoryRemoving: isRepositoryRemoving, 261 + hideSubtitle: true, 262 + moveDisabled: false, 263 + shortcutHint: nil 264 + ) 265 + } 266 + } 267 + } 268 + 220 269 // MARK: - Context menu. 221 270 222 - private struct WorktreeContextMenu: View { 271 + private struct SidebarItemContextMenu: View { 223 272 let worktree: Worktree 224 - let row: WorktreeRowModel 273 + let row: SidebarItemModel 225 274 @Bindable var store: StoreOf<RepositoriesFeature> 226 275 let selectedWorktreeIDs: Set<Worktree.ID> 227 276 @Shared(.settingsFile) private var settingsFile 228 277 229 - private var contextRows: [WorktreeRowModel] { 278 + private var contextRows: [SidebarItemModel] { 230 279 guard selectedWorktreeIDs.count > 1, selectedWorktreeIDs.contains(row.id) else { 231 280 return [row] 232 281 } ··· 234 283 return rows.isEmpty ? [row] : rows 235 284 } 236 285 286 + /// A bulk context menu only makes sense for selections whose rows 287 + /// are all of the same kind — the per-kind actions (archive, pin, 288 + /// branch-name copy, folder disk deletion) don't compose. Mixed 289 + /// selections surface no menu at all; the user-facing affordances 290 + /// for that state live in the multi-selection detail view. 291 + private var hasMixedKindSelection: Bool { 292 + contextRows.count > 1 && Set(contextRows.map(\.kind)).count > 1 293 + } 294 + 295 + private var isAllFoldersBulk: Bool { 296 + contextRows.count > 1 && contextRows.allSatisfy(\.isFolder) 297 + } 298 + 237 299 private var openActionSelection: OpenWorktreeAction { 238 300 @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings 239 301 return OpenWorktreeAction.fromSettingsID( ··· 243 305 } 244 306 245 307 var body: some View { 246 - let contextRows = contextRows 247 - let isBulkSelection = contextRows.count > 1 248 - let overrides = settingsFile.global.shortcutOverrides 308 + // A mixed folders + worktrees selection has no composable bulk 309 + // action, so we render no menu at all. The multi-selection 310 + // detail view explains what remains available per kind. 311 + if hasMixedKindSelection { 312 + EmptyView() 313 + } else { 314 + menuContents( 315 + contextRows: contextRows, 316 + isBulkSelection: contextRows.count > 1, 317 + overrides: settingsFile.global.shortcutOverrides 318 + ) 319 + } 320 + } 321 + 322 + @ViewBuilder 323 + private func menuContents( 324 + contextRows: [SidebarItemModel], 325 + isBulkSelection: Bool, 326 + overrides: [AppShortcutID: AppShortcutOverride] 327 + ) -> some View { 249 328 let archiveShortcut = AppShortcuts.archiveWorktree.effective(from: overrides) 250 329 let deleteShortcut = AppShortcuts.deleteWorktree.effective(from: overrides) 330 + let isAllFoldersBulk = isAllFoldersBulk 251 331 252 332 if !isBulkSelection { 253 333 openActions(overrides: overrides) ··· 280 360 NSPasteboard.general.clearContents() 281 361 NSPasteboard.general.setString(worktree.workingDirectory.path, forType: .string) 282 362 } 283 - Button("Copy as Branch Name") { 284 - NSPasteboard.general.clearContents() 285 - NSPasteboard.general.setString(worktree.name, forType: .string) 363 + if !row.isFolder { 364 + Button("Copy as Branch Name") { 365 + NSPasteboard.general.clearContents() 366 + NSPasteboard.general.setString(worktree.name, forType: .string) 367 + } 286 368 } 287 369 Divider() 370 + if row.isFolder { 371 + // Folder rows have no section-header ellipsis menu, so the 372 + // Settings entry lives alongside Delete in the context menu. 373 + Button("Folder Settings…", systemImage: "gear") { 374 + store.send(.openRepositorySettings(row.repositoryID)) 375 + } 376 + .help("Open folder settings") 377 + Divider() 378 + } 288 379 } 289 380 290 381 let archiveTargets = ··· 303 394 ) 304 395 } 305 396 306 - if !archiveTargets.isEmpty || !deleteTargets.isEmpty { 397 + if !archiveTargets.isEmpty { 307 398 let archiveLabel = isBulkSelection ? "Archive Worktrees…" : "Archive Worktree…" 308 399 Button(archiveLabel, systemImage: "archivebox") { 309 400 if archiveTargets.count == 1, let target = archiveTargets.first { ··· 313 404 } 314 405 } 315 406 .appKeyboardShortcut(archiveShortcut) 316 - .disabled(archiveTargets.isEmpty) 317 - 318 - let deleteLabel = isBulkSelection ? "Delete Worktrees…" : "Delete Worktree…" 407 + } 408 + if !deleteTargets.isEmpty { 409 + let deleteLabel = 410 + isBulkSelection 411 + ? (isAllFoldersBulk ? "Remove Folders…" : "Delete Worktrees…") 412 + : (row.isFolder ? "Remove Folder…" : "Delete Worktree…") 319 413 Button(deleteLabel, systemImage: "trash", role: .destructive) { 320 - if deleteTargets.count == 1, let target = deleteTargets.first { 321 - store.send(.requestDeleteWorktree(target.worktreeID, target.repositoryID)) 322 - } else { 323 - store.send(.requestDeleteWorktrees(deleteTargets)) 324 - } 414 + store.send(.requestDeleteSidebarItems(deleteTargets)) 325 415 } 326 416 .appKeyboardShortcut(deleteShortcut) 327 - .disabled(deleteTargets.isEmpty) 328 417 } 329 418 } 330 419
+68 -38
supacode/Features/Settings/Views/SettingsView.swift
··· 3 3 import SupacodeSettingsFeature 4 4 import SwiftUI 5 5 6 - /// Sidebar label that shows a GitHub owner avatar next to the repository name. 6 + /// Sidebar label that shows a GitHub owner avatar next to the 7 + /// repository name. Falls back to a git-branch symbol for git repos 8 + /// without an avatar and to a folder symbol for non-git folder 9 + /// entries. 7 10 private struct RepositoryLabel: View { 8 11 let name: String 9 12 let rootURL: URL 13 + let isGitRepository: Bool 10 14 11 15 @State private var avatarURL: URL? 12 16 ··· 14 18 Label { 15 19 Text(name) 16 20 } icon: { 17 - KFImage(avatarURL) 18 - .placeholder { 19 - Image(systemName: "folder") 20 - .padding(-3) 21 - .accessibilityHidden(true) 22 - } 23 - .resizable() 24 - .aspectRatio(1, contentMode: .fit) 25 - .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) 26 - .padding(3) 21 + if isGitRepository { 22 + KFImage(avatarURL) 23 + .placeholder { 24 + Image(systemName: "arrow.trianglehead.branch") 25 + .padding(-3) 26 + .accessibilityHidden(true) 27 + } 28 + .resizable() 29 + .aspectRatio(1, contentMode: .fit) 30 + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) 31 + .padding(3) 32 + } else { 33 + Image(systemName: "folder") 34 + .accessibilityHidden(true) 35 + } 27 36 } 28 37 .task(id: rootURL) { 38 + guard isGitRepository else { 39 + avatarURL = nil 40 + return 41 + } 29 42 avatarURL = await Self.ownerAvatarURL(for: rootURL) 30 43 } 31 44 } ··· 49 62 } 50 63 51 64 var body: some View { 52 - RepositoryLabel(name: repository.name, rootURL: repository.rootURL) 53 - .contentShape(Rectangle()) 54 - .accessibilityAddTraits(.isButton) 55 - .onTapGesture { 56 - guard !isSelected else { 57 - isExpanded.toggle() 58 - return 59 - } 60 - _ = settingsStore.send(.setSelection(.repository(repository.id))) 65 + RepositoryLabel( 66 + name: repository.name, 67 + rootURL: repository.rootURL, 68 + isGitRepository: repository.isGitRepository 69 + ) 70 + .contentShape(Rectangle()) 71 + .accessibilityAddTraits(.isButton) 72 + .onTapGesture { 73 + guard !isSelected else { 74 + isExpanded.toggle() 75 + return 61 76 } 77 + _ = settingsStore.send(.setSelection(.repository(repository.id))) 78 + } 62 79 } 63 80 } 64 81 ··· 86 103 87 104 Section("Repositories") { 88 105 ForEach(settingsStore.repositorySummaries, id: \.id) { repository in 89 - let isExpanded = Binding( 90 - get: { expandedRepositories.contains(repository.id) }, 91 - set: { expanded in 92 - if expanded { 93 - expandedRepositories.insert(repository.id) 94 - } else { 95 - expandedRepositories.remove(repository.id) 106 + if repository.isGitRepository { 107 + let isExpanded = Binding( 108 + get: { expandedRepositories.contains(repository.id) }, 109 + set: { expanded in 110 + if expanded { 111 + expandedRepositories.insert(repository.id) 112 + } else { 113 + expandedRepositories.remove(repository.id) 114 + } 96 115 } 116 + ) 117 + DisclosureGroup(isExpanded: isExpanded) { 118 + Label("General", systemImage: "gearshape") 119 + .tag(SettingsSection.repository(repository.id)) 120 + Label("Scripts", systemImage: "terminal") 121 + .tag(SettingsSection.repositoryScripts(repository.id)) 122 + } label: { 123 + RepositoryDisclosureLabel( 124 + repository: repository, 125 + settingsStore: settingsStore, 126 + isExpanded: isExpanded 127 + ) 97 128 } 98 - ) 99 - DisclosureGroup(isExpanded: isExpanded) { 100 - Label("General", systemImage: "gearshape") 101 - .tag(SettingsSection.repository(repository.id)) 102 - Label("Scripts", systemImage: "terminal") 103 - .tag(SettingsSection.repositoryScripts(repository.id)) 104 - } label: { 105 - RepositoryDisclosureLabel( 106 - repository: repository, 107 - settingsStore: settingsStore, 108 - isExpanded: isExpanded 129 + } else { 130 + // Folder entries go straight to the scripts page — no 131 + // general / disclosure row since the git settings don't 132 + // apply. Selection is expressed via the row tag so 133 + // selecting it updates `settingsStore.selection`. 134 + RepositoryLabel( 135 + name: repository.name, 136 + rootURL: repository.rootURL, 137 + isGitRepository: false 109 138 ) 139 + .tag(SettingsSection.repositoryScripts(repository.id)) 110 140 } 111 141 } 112 142 }
+7 -2
supacodeTests/AppFeatureCommandPaletteTests.swift
··· 149 149 AppFeature() 150 150 } 151 151 152 + let target = RepositoriesFeature.DeleteWorktreeTarget( 153 + worktreeID: worktree.id, repositoryID: repository.id) 152 154 let expectedAlert = AlertState<RepositoriesFeature.Alert> { 153 155 TextState("🚨 Delete worktree?") 154 156 } actions: { 155 - ButtonState(role: .destructive, action: .confirmDeleteWorktree(worktree.id, repository.id)) { 157 + ButtonState( 158 + role: .destructive, 159 + action: .confirmDeleteSidebarItems([target], disposition: .gitWorktreeDelete) 160 + ) { 156 161 TextState("Delete (⌘↩)") 157 162 } 158 163 ButtonState(role: .cancel) { ··· 163 168 } 164 169 165 170 await store.send(.commandPalette(.delegate(.removeWorktree(worktree.id, repository.id)))) 166 - await store.receive(\.repositories.requestDeleteWorktree) { 171 + await store.receive(\.repositories.requestDeleteSidebarItems) { 167 172 $0.repositories.alert = expectedAlert 168 173 } 169 174 }
+45 -2
supacodeTests/AppFeatureDeeplinkTests.swift
··· 105 105 106 106 await store.send(.deeplink(.worktree(id: worktree.id, action: .delete))) 107 107 #expect(store.state.deeplinkInputConfirmation == nil) 108 - await store.receive(\.repositories.deleteWorktreeConfirmed) 108 + await store.receive(\.repositories.deleteSidebarItemConfirmed) 109 109 } 110 110 111 111 @Test(.dependencies) func deleteWorktreeDeeplinkConfirmationAcceptedSendsDeleteConfirmed() async { ··· 137 137 $0.deeplinkInputConfirmation = nil 138 138 } 139 139 } 140 - await store.receive(\.repositories.deleteWorktreeConfirmed) 140 + await store.receive(\.repositories.deleteSidebarItemConfirmed) 141 141 await store.finish() 142 142 } 143 143 ··· 148 148 await store.send(.deeplink(.worktree(id: mainWorktree.id, action: .delete))) 149 149 #expect(store.state.deeplinkInputConfirmation == nil) 150 150 #expect(store.state.alert != nil) 151 + } 152 + 153 + @Test(.dependencies) func deleteFolderDeeplinkRoutesToFolderAlertPipeline() async { 154 + // Regression: folders have a synthetic main-worktree 155 + // (`workingDirectory == rootURL`), so the `isMainWorktree` gate 156 + // in the deeplink handler used to reject them with a 157 + // "main worktree not allowed" alert — making folders 158 + // undeletable via deeplink. Fix routes folder targets to 159 + // `.requestDeleteSidebarItems([target])` so the 3-button 160 + // folder confirmation fires. 161 + let folderRoot = "/tmp/folder-deeplink-\(UUID().uuidString)" 162 + let folderURL = URL(fileURLWithPath: folderRoot) 163 + let folderWorktree = Worktree( 164 + id: Repository.folderWorktreeID(for: folderURL), 165 + name: Repository.name(for: folderURL), 166 + detail: "", 167 + workingDirectory: folderURL, 168 + repositoryRootURL: folderURL 169 + ) 170 + let folderRepo = Repository( 171 + id: folderRoot, 172 + rootURL: folderURL, 173 + name: Repository.name(for: folderURL), 174 + worktrees: [folderWorktree], 175 + isGitRepository: false 176 + ) 177 + var repositoriesState = RepositoriesFeature.State() 178 + repositoriesState.repositories = [folderRepo] 179 + repositoriesState.repositoryRoots = [folderURL] 180 + repositoriesState.isInitialLoadComplete = true 181 + let store = TestStore( 182 + initialState: AppFeature.State( 183 + repositories: repositoriesState, 184 + settings: SettingsFeature.State() 185 + ) 186 + ) { 187 + AppFeature() 188 + } 189 + store.exhaustivity = .off 190 + 191 + await store.send(.deeplink(.worktree(id: folderWorktree.id, action: .delete))) 192 + await store.receive(\.repositories.requestDeleteSidebarItems) 193 + #expect(store.state.repositories.alert != nil, "folder alert should be presented") 151 194 } 152 195 153 196 @Test(.dependencies) func deleteWorktreeDeeplinkWithUnknownIDShowsAlert() async {
+35
supacodeTests/RepositoriesFeature+TestHelpers.swift
··· 1 + import Foundation 2 + 3 + @testable import supacode 4 + 5 + extension RepositoriesFeature.State { 6 + /// Seed an active removal batch for tests that drop straight 7 + /// into `.deleteSidebarItemConfirmed` / `.deleteScriptCompleted` 8 + /// without going through the confirm handler that normally 9 + /// mints the id and records dispositions. Callers pass the 10 + /// disposition each pending repo was confirmed with so the 11 + /// per-repo record stays coherent. Returns the batch id so 12 + /// tests can assert its lifecycle. 13 + /// 14 + /// Lives in the test target (not the main module) so production 15 + /// code can't accidentally seed the removal state machine 16 + /// externally — the `.requestDeleteSidebarItems` → 17 + /// `.confirmDeleteSidebarItems` flow owns that setup — and a 18 + /// misuse from production code would silently corrupt the batch 19 + /// aggregator. 20 + @discardableResult 21 + mutating func seedRemovalBatch( 22 + pending: [Repository.ID: RepositoriesFeature.DeleteDisposition], 23 + id: RepositoriesFeature.BatchID = UUID() 24 + ) -> RepositoriesFeature.BatchID { 25 + for (repositoryID, disposition) in pending { 26 + removingRepositoryIDs[repositoryID] = RepositoriesFeature.RepositoryRemovalRecord( 27 + disposition: disposition, batchID: id 28 + ) 29 + } 30 + activeRemovalBatches[id] = RepositoriesFeature.ActiveRemovalBatch( 31 + id: id, pending: Set(pending.keys) 32 + ) 33 + return id 34 + } 35 + }
+1725 -31
supacodeTests/RepositoriesFeatureTests.swift
··· 1681 1681 #expect(store.state.pendingWorktrees.isEmpty) 1682 1682 } 1683 1683 1684 - @Test func requestDeleteWorktreeShowsConfirmation() async { 1684 + @Test func requestDeleteSidebarItemShowsConfirmation() async { 1685 1685 let worktree = makeWorktree(id: "/tmp/wt", name: "owl") 1686 1686 let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 1687 1687 let store = TestStore(initialState: makeState(repositories: [repository])) { 1688 1688 RepositoriesFeature() 1689 1689 } 1690 1690 1691 + let target = RepositoriesFeature.DeleteWorktreeTarget( 1692 + worktreeID: worktree.id, repositoryID: repository.id) 1691 1693 let expectedAlert = AlertState<RepositoriesFeature.Alert> { 1692 1694 TextState("🚨 Delete worktree?") 1693 1695 } actions: { 1694 - ButtonState(role: .destructive, action: .confirmDeleteWorktree(worktree.id, repository.id)) { 1696 + ButtonState( 1697 + role: .destructive, 1698 + action: .confirmDeleteSidebarItems([target], disposition: .gitWorktreeDelete) 1699 + ) { 1695 1700 TextState("Delete (⌘↩)") 1696 1701 } 1697 1702 ButtonState(role: .cancel) { ··· 1701 1706 TextState("Delete \(worktree.name)? This deletes the worktree directory and its local branch.") 1702 1707 } 1703 1708 1704 - await store.send(.requestDeleteWorktree(worktree.id, repository.id)) { 1709 + await store.send(.requestDeleteSidebarItems([target])) { 1705 1710 $0.alert = expectedAlert 1706 1711 } 1707 1712 } 1708 1713 1709 - @Test func requestDeleteMainWorktreeShowsNotAllowedAlert() async { 1714 + @Test func requestDeleteMainWorktreeShowsNotAllowedAlertForSingleTarget() async { 1715 + // Single-target main git worktree delete (palette / hotkey / 1716 + // context-menu) surfaces the same "Delete not allowed" alert the 1717 + // deeplink path shows, so every entry point has consistent 1718 + // feedback instead of silently no-opping. 1710 1719 let mainWorktree = makeWorktree(id: "/tmp/repo", name: "main") 1711 1720 let repository = makeRepository(id: "/tmp/repo", worktrees: [mainWorktree]) 1712 1721 ··· 1714 1723 RepositoriesFeature() 1715 1724 } 1716 1725 1726 + let target = RepositoriesFeature.DeleteWorktreeTarget( 1727 + worktreeID: mainWorktree.id, repositoryID: repository.id) 1717 1728 let expectedAlert = AlertState<RepositoriesFeature.Alert> { 1718 1729 TextState("Delete not allowed") 1719 1730 } actions: { 1720 - ButtonState(role: .cancel) { 1721 - TextState("OK") 1722 - } 1731 + ButtonState(role: .cancel) { TextState("OK") } 1723 1732 } message: { 1724 1733 TextState("Deleting the main worktree is not allowed.") 1725 1734 } 1726 - 1727 - await store.send(.requestDeleteWorktree(mainWorktree.id, repository.id)) { 1735 + await store.send(.requestDeleteSidebarItems([target])) { 1728 1736 $0.alert = expectedAlert 1729 1737 } 1730 1738 } 1731 - @Test func requestDeleteWorktreesShowsBatchConfirmation() async { 1739 + 1740 + @Test func requestDeleteMainWorktreeInBulkRemainsSilentlyFiltered() async { 1741 + // Bulk selection that mixes the main worktree with an actual 1742 + // deletable target must keep the main filter silent so the rest 1743 + // of the batch proceeds; only single-target rejections surface 1744 + // feedback. 1745 + let mainWorktree = makeWorktree(id: "/tmp/repo", name: "main") 1746 + let feature = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") 1747 + let repository = makeRepository(id: "/tmp/repo", worktrees: [mainWorktree, feature]) 1748 + 1749 + let store = TestStore(initialState: makeState(repositories: [repository])) { 1750 + RepositoriesFeature() 1751 + } 1752 + 1753 + let targets = [ 1754 + RepositoriesFeature.DeleteWorktreeTarget( 1755 + worktreeID: mainWorktree.id, repositoryID: repository.id), 1756 + RepositoriesFeature.DeleteWorktreeTarget( 1757 + worktreeID: feature.id, repositoryID: repository.id), 1758 + ] 1759 + await store.send(.requestDeleteSidebarItems(targets)) { 1760 + $0.alert = AlertState { 1761 + TextState("🚨 Delete worktree?") 1762 + } actions: { 1763 + ButtonState( 1764 + role: .destructive, 1765 + action: .confirmDeleteSidebarItems([targets[1]], disposition: .gitWorktreeDelete) 1766 + ) { 1767 + TextState("Delete (⌘↩)") 1768 + } 1769 + ButtonState(role: .cancel) { 1770 + TextState("Cancel") 1771 + } 1772 + } message: { 1773 + TextState( 1774 + "Delete \(feature.name)? This deletes the worktree directory and its local branch." 1775 + ) 1776 + } 1777 + } 1778 + } 1779 + @Test func requestDeleteSidebarItemsShowsBatchConfirmation() async { 1732 1780 let worktree1 = makeWorktree(id: "/tmp/repo/wt1", name: "owl", repoRoot: "/tmp/repo") 1733 1781 let worktree2 = makeWorktree(id: "/tmp/repo/wt2", name: "hawk", repoRoot: "/tmp/repo") 1734 1782 let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree1, worktree2]) ··· 1743 1791 let expectedAlert = AlertState<RepositoriesFeature.Alert> { 1744 1792 TextState("🚨 Delete 2 worktrees?") 1745 1793 } actions: { 1746 - ButtonState(role: .destructive, action: .confirmDeleteWorktrees(targets)) { 1794 + ButtonState( 1795 + role: .destructive, 1796 + action: .confirmDeleteSidebarItems(targets, disposition: .gitWorktreeDelete) 1797 + ) { 1747 1798 TextState("Delete 2 (⌘↩)") 1748 1799 } 1749 1800 ButtonState(role: .cancel) { ··· 1753 1804 TextState("Delete 2 worktrees? This deletes the worktree directories and their local branches.") 1754 1805 } 1755 1806 1756 - await store.send(.requestDeleteWorktrees(targets)) { 1807 + await store.send(.requestDeleteSidebarItems(targets)) { 1757 1808 $0.alert = expectedAlert 1758 1809 } 1759 1810 } ··· 1786 1837 } 1787 1838 } 1788 1839 1840 + @Test func requestArchiveWorktreeForFolderShowsActionNotAvailable() async { 1841 + // S1: the deeplink layer rejects archive/pin/unpin on folders, 1842 + // but the hotkey / context-menu path used to silently no-op 1843 + // because the synthetic main-worktree satisfies `isMainWorktree` 1844 + // geometrically. Surface the same "Action not available" alert 1845 + // the deeplink shows. 1846 + let folderRoot = "/tmp/folder-archive-\(UUID().uuidString)" 1847 + let folderURL = URL(fileURLWithPath: folderRoot) 1848 + let folderWorktree = Worktree( 1849 + id: Repository.folderWorktreeID(for: folderURL), 1850 + name: Repository.name(for: folderURL), detail: "", 1851 + workingDirectory: folderURL, repositoryRootURL: folderURL 1852 + ) 1853 + let folderRepo = Repository( 1854 + id: folderRoot, rootURL: folderURL, name: Repository.name(for: folderURL), 1855 + worktrees: IdentifiedArray(uniqueElements: [folderWorktree]), 1856 + isGitRepository: false 1857 + ) 1858 + let store = TestStore(initialState: makeState(repositories: [folderRepo])) { 1859 + RepositoriesFeature() 1860 + } 1861 + 1862 + // The helper produces a per-action title + body so users know 1863 + // which action they just tried. Keep each expected alert 1864 + // narrow to the one being exercised. 1865 + func expectedAlert(name: String) -> AlertState<RepositoriesFeature.Alert> { 1866 + AlertState { 1867 + TextState("\(name) not available") 1868 + } actions: { 1869 + ButtonState(role: .cancel) { TextState("OK") } 1870 + } message: { 1871 + TextState("\(name) only applies to git repositories.") 1872 + } 1873 + } 1874 + await store.send(.requestArchiveWorktree(folderWorktree.id, folderRepo.id)) { 1875 + $0.alert = expectedAlert(name: "Archive") 1876 + } 1877 + await store.send(.alert(.dismiss)) { $0.alert = nil } 1878 + await store.send(.pinWorktree(folderWorktree.id)) { 1879 + $0.alert = expectedAlert(name: "Pin") 1880 + } 1881 + await store.send(.alert(.dismiss)) { $0.alert = nil } 1882 + await store.send(.unpinWorktree(folderWorktree.id)) { 1883 + $0.alert = expectedAlert(name: "Unpin") 1884 + } 1885 + } 1886 + 1789 1887 @Test func requestArchiveWorktreesShowsBatchConfirmation() async { 1790 1888 let worktree1 = makeWorktree(id: "/tmp/repo/wt1", name: "owl", repoRoot: "/tmp/repo") 1791 1889 let worktree2 = makeWorktree(id: "/tmp/repo/wt2", name: "hawk", repoRoot: "/tmp/repo") ··· 2396 2494 2397 2495 // MARK: - Delete Script 2398 2496 2399 - @Test(.dependencies) func deleteWorktreeConfirmedDelegatesDeleteScript() async { 2497 + @Test(.dependencies) func deleteSidebarItemConfirmedDelegatesDeleteScript() async { 2400 2498 let repoRoot = "/tmp/repo" 2401 2499 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 2402 2500 let featureWorktree = makeWorktree( ··· 2415 2513 RepositoriesFeature() 2416 2514 } 2417 2515 2418 - await store.send(.deleteWorktreeConfirmed(featureWorktree.id, repository.id)) { 2516 + await store.send(.deleteSidebarItemConfirmed(featureWorktree.id, repository.id)) { 2419 2517 $0.deleteScriptWorktreeIDs = [featureWorktree.id] 2420 2518 } 2421 2519 await store.receive(\.delegate.runBlockingScript) ··· 2521 2619 await store.send(.deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0, tabId: nil)) 2522 2620 } 2523 2621 2524 - @Test(.dependencies) func deleteWorktreeConfirmedSkipsScriptWhenEmpty() async { 2622 + @Test(.dependencies) func deleteSidebarItemConfirmedSkipsScriptWhenEmpty() async { 2525 2623 let repoRoot = "/tmp/repo" 2526 2624 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 2527 2625 let featureWorktree = makeWorktree( ··· 2543 2641 $0.gitClient.worktrees = { _ in [mainWorktree] } 2544 2642 } 2545 2643 2546 - await store.send(.deleteWorktreeConfirmed(featureWorktree.id, repository.id)) 2644 + await store.send(.deleteSidebarItemConfirmed(featureWorktree.id, repository.id)) 2547 2645 await store.receive(\.deleteWorktreeApply) { 2548 2646 $0.deletingWorktreeIDs = [featureWorktree.id] 2549 2647 } ··· 2587 2685 } 2588 2686 } 2589 2687 2590 - @Test func deleteWorktreeConfirmedNoopsWhenAlreadyArchiving() async { 2688 + @Test func deleteSidebarItemConfirmedNoopsWhenAlreadyArchiving() async { 2591 2689 let repoRoot = "/tmp/repo" 2592 2690 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 2593 2691 let featureWorktree = makeWorktree( ··· 2602 2700 RepositoriesFeature() 2603 2701 } 2604 2702 2605 - await store.send(.deleteWorktreeConfirmed(featureWorktree.id, repository.id)) 2703 + await store.send(.deleteSidebarItemConfirmed(featureWorktree.id, repository.id)) 2606 2704 } 2607 2705 2608 2706 @Test func repositoriesLoadedKeepsDeleteScriptInFlightUntilSuccessCompletion() async { ··· 2826 2924 #expect(store.state.repositories[id: repository.id]?.worktrees[id: worktree.id]?.createdAt == createdAt) 2827 2925 } 2828 2926 2829 - @Test func orderedWorktreeRowsAreGlobal() { 2927 + @Test func orderedSidebarItemsAreGlobal() { 2830 2928 let repoA = makeRepository( 2831 2929 id: "/tmp/repo-a", 2832 2930 worktrees: [ ··· 2843 2941 let state = makeState(repositories: [repoA, repoB]) 2844 2942 2845 2943 expectNoDifference( 2846 - state.orderedWorktreeRows().map(\.id), 2944 + state.orderedSidebarItems().map(\.id), 2847 2945 [ 2848 2946 "/tmp/repo-a/wt1", 2849 2947 "/tmp/repo-a/wt2", ··· 2852 2950 ) 2853 2951 } 2854 2952 2855 - @Test func orderedWorktreeRowsRespectRepositoryOrderIDs() { 2953 + @Test func orderedSidebarItemsRespectRepositoryOrderIDs() { 2856 2954 let repoA = makeRepository( 2857 2955 id: "/tmp/repo-a", 2858 2956 worktrees: [ ··· 2872 2970 } 2873 2971 2874 2972 expectNoDifference( 2875 - state.orderedWorktreeRows().map(\.id), 2973 + state.orderedSidebarItems().map(\.id), 2876 2974 [ 2877 2975 "/tmp/repo-b/wt2", 2878 2976 "/tmp/repo-a/wt1", ··· 2880 2978 ) 2881 2979 } 2882 2980 2883 - @Test func orderedWorktreeRowsCanFilterCollapsedRepositoriesForHotkeys() { 2981 + @Test func orderedSidebarItemsCanFilterCollapsedRepositoriesForHotkeys() { 2884 2982 let repoA = makeRepository( 2885 2983 id: "/tmp/repo-a", 2886 2984 worktrees: [ ··· 2900 2998 } 2901 2999 2902 3000 expectNoDifference( 2903 - state.orderedWorktreeRows(includingRepositoryIDs: [repoB.id]).map(\.id), 3001 + state.orderedSidebarItems(includingRepositoryIDs: [repoB.id]).map(\.id), 2904 3002 [ 2905 3003 "/tmp/repo-b/wt2" 2906 3004 ] ··· 3381 3479 let store = TestStore(initialState: state) { 3382 3480 RepositoriesFeature() 3383 3481 } 3384 - // Exhaustivity is off because `deleteWorktreeConfirmed` triggers 3482 + // Exhaustivity is off because `deleteSidebarItemConfirmed` triggers 3385 3483 // async git operations that require extensive dependency mocking. 3386 3484 store.exhaustivity = .off 3387 3485 let mergedPullRequest = makePullRequest(state: "MERGED", headRefName: featureWorktree.name) ··· 3398 3496 pullRequest: mergedPullRequest 3399 3497 ) 3400 3498 } 3401 - await store.receive(\.deleteWorktreeConfirmed) 3499 + await store.receive(\.deleteSidebarItemConfirmed) 3402 3500 } 3403 3501 3404 3502 @Test func repositoryPullRequestsLoadedDoesNothingWhenMergedWorktreeActionNil() async { ··· 4033 4131 store.exhaustivity = .off 4034 4132 4035 4133 await store.send(.autoDeleteExpiredArchivedWorktrees) 4036 - await store.receive(\.deleteWorktreeConfirmed) 4134 + await store.receive(\.deleteSidebarItemConfirmed) 4037 4135 } 4038 4136 4039 4137 @Test func autoDeleteExpiredArchivedWorktreesSkipsNonExpired() async { ··· 4177 4275 $0.autoDeleteArchivedWorktreesAfterDays = .sevenDays 4178 4276 } 4179 4277 await store.receive(\.autoDeleteExpiredArchivedWorktrees) 4180 - await store.receive(\.deleteWorktreeConfirmed) 4278 + await store.receive(\.deleteSidebarItemConfirmed) 4181 4279 } 4182 4280 4183 4281 @Test func autoDeleteExpiredArchivedWorktreesSkipsDeleteScriptInProgress() async { ··· 4268 4366 store.exhaustivity = .off 4269 4367 4270 4368 await store.send(.autoDeleteExpiredArchivedWorktrees) 4271 - await store.receive(\.deleteWorktreeConfirmed) 4369 + await store.receive(\.deleteSidebarItemConfirmed) 4272 4370 } 4273 4371 4274 4372 @Test func repositoriesLoadedTriggersAutoDeleteWhenEnabled() async { ··· 4307 4405 ) 4308 4406 ) 4309 4407 await store.receive(\.autoDeleteExpiredArchivedWorktrees) 4310 - await store.receive(\.deleteWorktreeConfirmed) 4408 + await store.receive(\.deleteSidebarItemConfirmed) 4311 4409 } 4312 4410 4313 4411 @Test func setAutoDeleteDaysNilDoesNotTriggerAutoDelete() async { ··· 4354 4452 ) 4355 4453 ) 4356 4454 await store.receive(\.autoDeleteExpiredArchivedWorktrees) 4357 - await store.receive(\.deleteWorktreeConfirmed) 4455 + await store.receive(\.deleteSidebarItemConfirmed) 4358 4456 } 4359 4457 4360 4458 // MARK: - Select Next/Previous Worktree ··· 4769 4867 await store.receive(\.delegate.repositoriesChanged) 4770 4868 await store.receive(\.delegate.selectedWorktreeChanged) 4771 4869 await store.finish() 4870 + } 4871 + 4872 + // MARK: - Folder (non-git) repositories. 4873 + 4874 + @Test func isGitRepositoryDetectsDotGitDirectory() throws { 4875 + let tempDir = FileManager.default.temporaryDirectory 4876 + .appending(path: "supa-\(UUID().uuidString)", directoryHint: .isDirectory) 4877 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 4878 + defer { try? FileManager.default.removeItem(at: tempDir) } 4879 + let dotGit = tempDir.appending(path: ".git", directoryHint: .isDirectory) 4880 + try FileManager.default.createDirectory(at: dotGit, withIntermediateDirectories: true) 4881 + 4882 + #expect(Repository.isGitRepository(at: tempDir)) 4883 + } 4884 + 4885 + @Test func isGitRepositoryRecognizesDotGitWorktreePointerFile() throws { 4886 + let tempDir = FileManager.default.temporaryDirectory 4887 + .appending(path: "supa-\(UUID().uuidString)", directoryHint: .isDirectory) 4888 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 4889 + defer { try? FileManager.default.removeItem(at: tempDir) } 4890 + // Linked worktrees have a `.git` file (not directory) pointing 4891 + // at the parent's gitdir — the classifier must honor both. 4892 + let pointer = tempDir.appending(path: ".git", directoryHint: .notDirectory) 4893 + try "gitdir: /somewhere/.git/worktrees/foo\n".write(to: pointer, atomically: true, encoding: .utf8) 4894 + 4895 + #expect(Repository.isGitRepository(at: tempDir)) 4896 + } 4897 + 4898 + @Test func isGitRepositoryReturnsFalseForPlainDirectory() throws { 4899 + let tempDir = FileManager.default.temporaryDirectory 4900 + .appending(path: "supa-\(UUID().uuidString)", directoryHint: .isDirectory) 4901 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 4902 + defer { try? FileManager.default.removeItem(at: tempDir) } 4903 + 4904 + #expect(!Repository.isGitRepository(at: tempDir)) 4905 + } 4906 + 4907 + @Test func isGitRepositoryRecognizesBareAndDotGitRootNames() { 4908 + #expect(Repository.isGitRepository(at: URL(fileURLWithPath: "/tmp/repo/.bare"))) 4909 + #expect(Repository.isGitRepository(at: URL(fileURLWithPath: "/tmp/repo/.git"))) 4910 + } 4911 + 4912 + @Test func isGitRepositoryRecognizesBareCloneConvention() throws { 4913 + // `git clone --bare` produces `<name>.git/` with HEAD + objects/ + 4914 + // refs/ at the root (no `.git` metadata file, no `.bare` rename). 4915 + let bareRoot = URL(fileURLWithPath: "/tmp/\(UUID().uuidString)-myrepo.git") 4916 + let fileManager = FileManager.default 4917 + try fileManager.createDirectory(at: bareRoot, withIntermediateDirectories: true) 4918 + try fileManager.createDirectory(at: bareRoot.appending(path: "objects"), withIntermediateDirectories: true) 4919 + try fileManager.createDirectory(at: bareRoot.appending(path: "refs"), withIntermediateDirectories: true) 4920 + try Data("ref: refs/heads/main\n".utf8).write(to: bareRoot.appending(path: "HEAD")) 4921 + defer { try? fileManager.removeItem(at: bareRoot) } 4922 + 4923 + #expect(Repository.isGitRepository(at: bareRoot)) 4924 + 4925 + // A plain directory whose name happens to end in `.git` is not a bare repo. 4926 + let fakeRoot = URL(fileURLWithPath: "/tmp/\(UUID().uuidString)-notbare.git") 4927 + try fileManager.createDirectory(at: fakeRoot, withIntermediateDirectories: true) 4928 + defer { try? fileManager.removeItem(at: fakeRoot) } 4929 + 4930 + #expect(Repository.isGitRepository(at: fakeRoot) == false) 4931 + } 4932 + 4933 + @Test func loadPersistedRepositoriesClassifiesNonGitPathAsFolder() async { 4934 + let repoRoot = "/tmp/\(UUID().uuidString)-folder" 4935 + let rootURL = URL(fileURLWithPath: repoRoot) 4936 + 4937 + let store = TestStore(initialState: RepositoriesFeature.State()) { 4938 + RepositoriesFeature() 4939 + } withDependencies: { 4940 + $0.repositoryPersistence.loadRoots = { [repoRoot] } 4941 + $0.gitClient.isGitRepository = { _ in false } 4942 + $0.gitClient.worktrees = { _ in 4943 + Issue.record("worktrees() must not be called for folder repositories") 4944 + return [] 4945 + } 4946 + } 4947 + 4948 + await store.send(.loadPersistedRepositories) 4949 + let folderWorktree = Worktree( 4950 + id: Repository.folderWorktreeID(for: rootURL), 4951 + name: Repository.name(for: rootURL), 4952 + detail: "", 4953 + workingDirectory: rootURL, 4954 + repositoryRootURL: rootURL 4955 + ) 4956 + let folderRepo = Repository( 4957 + id: repoRoot, 4958 + rootURL: rootURL, 4959 + name: Repository.name(for: rootURL), 4960 + worktrees: IdentifiedArray(uniqueElements: [folderWorktree]), 4961 + isGitRepository: false 4962 + ) 4963 + await store.receive(\.repositoriesLoaded) { 4964 + $0.repositories = [folderRepo] 4965 + $0.repositoryRoots = [rootURL] 4966 + $0.isInitialLoadComplete = true 4967 + } 4968 + await store.receive(\.delegate.repositoriesChanged) 4969 + await store.finish() 4970 + } 4971 + 4972 + @Test func loadPersistedRepositoriesSurfacesMissingFolderAsFailureRow() async { 4973 + // Regression: folder-kind roots silently became empty folder 4974 + // repositories when the directory no longer existed on disk. 4975 + // Users who deleted a tracked folder from Finder saw a row 4976 + // with no indication that the path was gone. The loader now 4977 + // routes missing roots through `loadFailuresByID` so the 4978 + // sidebar renders the error row the way git failures do. 4979 + let repoRoot = "/tmp/\(UUID().uuidString)-missing-folder" 4980 + 4981 + let store = TestStore(initialState: RepositoriesFeature.State()) { 4982 + RepositoriesFeature() 4983 + } withDependencies: { 4984 + $0.repositoryPersistence.loadRoots = { [repoRoot] } 4985 + $0.gitClient.rootDirectoryExists = { _ in false } 4986 + $0.gitClient.isGitRepository = { _ in 4987 + Issue.record("isGitRepository() must not be called once the root is known to be missing") 4988 + return false 4989 + } 4990 + $0.gitClient.worktrees = { _ in 4991 + Issue.record("worktrees() must not be called for a missing root") 4992 + return [] 4993 + } 4994 + } 4995 + 4996 + await store.send(.loadPersistedRepositories) 4997 + await store.receive(\.repositoriesLoaded) { 4998 + $0.repositories = [] 4999 + $0.repositoryRoots = [URL(fileURLWithPath: repoRoot)] 5000 + $0.isInitialLoadComplete = true 5001 + $0.loadFailuresByID = [ 5002 + repoRoot: "Directory not found at \(repoRoot). It may have been moved or deleted." 5003 + ] 5004 + } 5005 + await store.finish() 5006 + } 5007 + 5008 + @Test func loadPersistedRepositoriesClassifiesMixedGitAndFolderRoots() async { 5009 + let gitRoot = "/tmp/\(UUID().uuidString)-git" 5010 + let folderRoot = "/tmp/\(UUID().uuidString)-folder" 5011 + let gitWorktree = makeWorktree(id: "\(gitRoot)/main", name: "main", repoRoot: gitRoot) 5012 + 5013 + let store = TestStore(initialState: RepositoriesFeature.State()) { 5014 + RepositoriesFeature() 5015 + } withDependencies: { 5016 + $0.repositoryPersistence.loadRoots = { [gitRoot, folderRoot] } 5017 + $0.gitClient.isGitRepository = { $0.path(percentEncoded: false) == gitRoot } 5018 + $0.gitClient.worktrees = { root in 5019 + #expect(root.path(percentEncoded: false) == gitRoot) 5020 + return [gitWorktree] 5021 + } 5022 + } 5023 + 5024 + await store.send(.loadPersistedRepositories) 5025 + await store.receive(\.repositoriesLoaded) { 5026 + $0.repositories = [ 5027 + Repository( 5028 + id: gitRoot, 5029 + rootURL: URL(fileURLWithPath: gitRoot), 5030 + name: URL(fileURLWithPath: gitRoot).lastPathComponent, 5031 + worktrees: [gitWorktree], 5032 + isGitRepository: true 5033 + ), 5034 + { 5035 + let url = URL(fileURLWithPath: folderRoot) 5036 + let synthetic = Worktree( 5037 + id: Repository.folderWorktreeID(for: url), 5038 + name: Repository.name(for: url), 5039 + detail: "", 5040 + workingDirectory: url, 5041 + repositoryRootURL: url 5042 + ) 5043 + return Repository( 5044 + id: folderRoot, 5045 + rootURL: url, 5046 + name: Repository.name(for: url), 5047 + worktrees: [synthetic], 5048 + isGitRepository: false 5049 + ) 5050 + }(), 5051 + ] 5052 + $0.repositoryRoots = [gitRoot, folderRoot].map { URL(fileURLWithPath: $0) } 5053 + $0.isInitialLoadComplete = true 5054 + } 5055 + await store.receive(\.delegate.repositoriesChanged) 5056 + await store.finish() 5057 + } 5058 + 5059 + @Test func openRepositoriesWithNonGitDirectoryAppearsImmediately() async throws { 5060 + // Reproduces the "folders don't appear immediately after being 5061 + // added" bug: dropping a non-git directory should flow through 5062 + // `.openRepositoriesFinished` and show up in `state.repositories` 5063 + // plus `state.repositoryRoots` on the next render tick. 5064 + let tempDir = FileManager.default.temporaryDirectory 5065 + .appending(path: "supa-\(UUID().uuidString)", directoryHint: .isDirectory) 5066 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 5067 + defer { try? FileManager.default.removeItem(at: tempDir) } 5068 + let standardizedURL = tempDir.standardizedFileURL 5069 + let rootID = standardizedURL.path(percentEncoded: false) 5070 + 5071 + let store = TestStore(initialState: RepositoriesFeature.State()) { 5072 + RepositoriesFeature() 5073 + } withDependencies: { 5074 + $0.repositoryPersistence.loadRoots = { [] } 5075 + $0.repositoryPersistence.saveRoots = { _ in } 5076 + $0.gitClient.repoRoot = { _ in 5077 + throw GitClientError.commandFailed(command: "wt root", message: "not a git repository") 5078 + } 5079 + $0.gitClient.isGitRepository = { _ in false } 5080 + $0.gitClient.worktrees = { _ in 5081 + Issue.record("worktrees() must not be called for folder repositories") 5082 + return [] 5083 + } 5084 + $0.analyticsClient.capture = { _, _ in } 5085 + } 5086 + 5087 + let folderWorktree = Worktree( 5088 + id: Repository.folderWorktreeID(for: standardizedURL), 5089 + name: Repository.name(for: standardizedURL), 5090 + detail: "", 5091 + workingDirectory: standardizedURL, 5092 + repositoryRootURL: standardizedURL 5093 + ) 5094 + let folderRepo = Repository( 5095 + id: rootID, 5096 + rootURL: standardizedURL, 5097 + name: Repository.name(for: standardizedURL), 5098 + worktrees: IdentifiedArray(uniqueElements: [folderWorktree]), 5099 + isGitRepository: false 5100 + ) 5101 + 5102 + await store.send(.openRepositories([tempDir])) 5103 + await store.receive(\.openRepositoriesFinished) { 5104 + $0.repositories = [folderRepo] 5105 + $0.repositoryRoots = [standardizedURL] 5106 + $0.isInitialLoadComplete = true 5107 + } 5108 + await store.receive(\.delegate.repositoriesChanged) 5109 + await store.finish() 5110 + } 5111 + 5112 + @Test func worktreesForInfoWatcherSkipsFolderRepositories() { 5113 + let gitWorktree = makeWorktree(id: "/tmp/git/main", name: "main", repoRoot: "/tmp/git") 5114 + let gitRepo = Repository( 5115 + id: "/tmp/git", 5116 + rootURL: URL(fileURLWithPath: "/tmp/git"), 5117 + name: "git", 5118 + worktrees: [gitWorktree], 5119 + isGitRepository: true 5120 + ) 5121 + let folderURL = URL(fileURLWithPath: "/tmp/folder") 5122 + let folderWorktree = Worktree( 5123 + id: Repository.folderWorktreeID(for: folderURL), 5124 + name: "folder", 5125 + detail: "", 5126 + workingDirectory: folderURL, 5127 + repositoryRootURL: folderURL 5128 + ) 5129 + let folderRepo = Repository( 5130 + id: "/tmp/folder", 5131 + rootURL: folderURL, 5132 + name: "folder", 5133 + worktrees: [folderWorktree], 5134 + isGitRepository: false 5135 + ) 5136 + var state = RepositoriesFeature.State() 5137 + state.repositories = [gitRepo, folderRepo] 5138 + 5139 + #expect(state.worktreesForInfoWatcher() == [gitWorktree]) 5140 + } 5141 + 5142 + @Test func requestDeleteSidebarItemForFolderSkipsMainWorktreeLockAndRoutesToRepositoryRemoved() async { 5143 + // Folders pipe their "Delete Folder…" context-menu action 5144 + // through `.requestDeleteSidebarItems` using the synthetic main 5145 + // worktree. The usual main-worktree lock would normally refuse 5146 + // it, but the reducer is expected to recognize folder repos and 5147 + // proceed, show a folder-flavored alert, and on confirm route 5148 + // into `.deleteSidebarItemConfirmed` → `.repositoryRemovalCompleted` 5149 + // → `.repositoriesRemoved` (no git `removeWorktree` since there 5150 + // is none). 5151 + let folderRoot = "/tmp/\(UUID().uuidString)-folder" 5152 + let folderURL = URL(fileURLWithPath: folderRoot) 5153 + let folderWorktree = Worktree( 5154 + id: Repository.folderWorktreeID(for: folderURL), 5155 + name: Repository.name(for: folderURL), 5156 + detail: "", 5157 + workingDirectory: folderURL, 5158 + repositoryRootURL: folderURL 5159 + ) 5160 + let folderRepo = Repository( 5161 + id: folderRoot, 5162 + rootURL: folderURL, 5163 + name: Repository.name(for: folderURL), 5164 + worktrees: IdentifiedArray(uniqueElements: [folderWorktree]), 5165 + isGitRepository: false 5166 + ) 5167 + 5168 + var state = RepositoriesFeature.State() 5169 + state.repositories = [folderRepo] 5170 + state.repositoryRoots = [folderURL] 5171 + state.isInitialLoadComplete = true 5172 + 5173 + let store = TestStore(initialState: state) { 5174 + RepositoriesFeature() 5175 + } withDependencies: { 5176 + $0.repositoryPersistence.loadRoots = { [folderRoot] } 5177 + $0.repositoryPersistence.saveRoots = { _ in } 5178 + $0.gitClient.isGitRepository = { _ in false } 5179 + $0.gitClient.worktrees = { _ in [] } 5180 + $0.analyticsClient.capture = { _, _ in } 5181 + $0.uuid = .incrementing 5182 + } 5183 + 5184 + let folderTarget = RepositoriesFeature.DeleteWorktreeTarget( 5185 + worktreeID: folderWorktree.id, repositoryID: folderRepo.id) 5186 + await store.send(.requestDeleteSidebarItems([folderTarget])) { 5187 + $0.alert = AlertState { 5188 + TextState("Remove folder?") 5189 + } actions: { 5190 + ButtonState( 5191 + action: .confirmDeleteSidebarItems([folderTarget], disposition: .folderUnlink) 5192 + ) { 5193 + TextState("Remove from Supacode") 5194 + } 5195 + ButtonState( 5196 + role: .destructive, 5197 + action: .confirmDeleteSidebarItems([folderTarget], disposition: .folderTrash) 5198 + ) { 5199 + TextState("Delete from disk") 5200 + } 5201 + ButtonState(role: .cancel) { 5202 + TextState("Cancel") 5203 + } 5204 + } message: { 5205 + TextState( 5206 + "Remove \(folderWorktree.name)? Choose \"Remove from Supacode\" to stop " 5207 + + "managing the folder (it stays on disk)" 5208 + + ", or \"Delete from disk\" to move the folder to the Trash." 5209 + ) 5210 + } 5211 + } 5212 + 5213 + store.exhaustivity = .off(showSkippedAssertions: false) 5214 + await store.send( 5215 + .alert(.presented(.confirmDeleteSidebarItems([folderTarget], disposition: .folderUnlink))) 5216 + ) 5217 + // The plural confirm handler sets up the batch, fans into 5218 + // `.deleteSidebarItemConfirmed`, the per-target completion 5219 + // drains into `.repositoryRemovalCompleted`, and the batch 5220 + // terminal `.repositoriesRemoved([id])` does the one-shot 5221 + // cleanup. Assert the key delegate hops so future regressions 5222 + // that skip them don't silently pass, then drain the rest. 5223 + await store.receive(\.repositoriesRemoved) 5224 + await store.receive(\.delegate.selectedWorktreeChanged) 5225 + await store.skipReceivedActions() 5226 + #expect(store.state.repositories.isEmpty) 5227 + #expect(store.state.removingRepositoryIDs[folderRepo.id] == nil) 5228 + } 5229 + 5230 + @Test func requestDeleteRepositoryForFolderConfirmsAndRemovesRoot() async { 5231 + // Legacy path: `.requestDeleteRepository` also works for folders 5232 + // (it just skips the blocking-script branch; no worktrees to 5233 + // archive either), but the primary UI surface uses the 5234 + // `.requestDeleteSidebarItems` path tested above. 5235 + let folderRoot = "/tmp/\(UUID().uuidString)-folder" 5236 + let folderURL = URL(fileURLWithPath: folderRoot) 5237 + let folderWorktree = Worktree( 5238 + id: Repository.folderWorktreeID(for: folderURL), 5239 + name: Repository.name(for: folderURL), 5240 + detail: "", 5241 + workingDirectory: folderURL, 5242 + repositoryRootURL: folderURL 5243 + ) 5244 + let folderRepo = Repository( 5245 + id: folderRoot, 5246 + rootURL: folderURL, 5247 + name: Repository.name(for: folderURL), 5248 + worktrees: IdentifiedArray(uniqueElements: [folderWorktree]), 5249 + isGitRepository: false 5250 + ) 5251 + 5252 + var state = RepositoriesFeature.State() 5253 + state.repositories = [folderRepo] 5254 + state.repositoryRoots = [folderURL] 5255 + state.isInitialLoadComplete = true 5256 + 5257 + let store = TestStore(initialState: state) { 5258 + RepositoriesFeature() 5259 + } withDependencies: { 5260 + $0.repositoryPersistence.loadRoots = { [folderRoot] } 5261 + $0.repositoryPersistence.saveRoots = { _ in } 5262 + $0.gitClient.isGitRepository = { _ in false } 5263 + $0.gitClient.worktrees = { _ in [] } 5264 + $0.analyticsClient.capture = { _, _ in } 5265 + $0.uuid = .incrementing 5266 + } 5267 + store.exhaustivity = .off(showSkippedAssertions: false) 5268 + 5269 + await store.send(.requestDeleteRepository(folderRepo.id)) 5270 + #expect(store.state.alert != nil) 5271 + await store.send(.alert(.presented(.confirmDeleteRepository(folderRepo.id)))) 5272 + // Section-level remove flows through batch-of-1: 5273 + // .confirmDeleteRepository → .repositoryRemovalCompleted (success) 5274 + // → .repositoriesRemoved([id]) → reconciliation. Assert the 5275 + // terminal + delegate fan-out so drops don't go unnoticed. 5276 + await store.receive(\.repositoryRemovalCompleted) 5277 + await store.receive(\.repositoriesRemoved) 5278 + await store.receive(\.delegate.selectedWorktreeChanged) 5279 + await store.skipReceivedActions() 5280 + #expect(store.state.repositories.isEmpty) 5281 + #expect(store.state.removingRepositoryIDs[folderRepo.id] == nil) 5282 + } 5283 + 5284 + @Test func deleteSidebarItemConfirmedRunsBlockingDeleteScriptForFolder() async { 5285 + // When a delete script is defined, folder deletion piggy-backs on 5286 + // the worktree-delete blocking-script pipeline: the reducer marks 5287 + // the folder as "removing", delegates the script run, and only 5288 + // signals `.repositoryRemovalCompleted` (drained by the batch 5289 + // aggregator into a single `.repositoriesRemoved`) after 5290 + // `.deleteScriptCompleted` reports exit 0 — so the folder stays 5291 + // visible with a progress indicator while the script runs and 5292 + // `gitClient.removeWorktree` is never called. 5293 + let folderRoot = "/tmp/\(UUID().uuidString)-folder" 5294 + let folderURL = URL(fileURLWithPath: folderRoot) 5295 + let folderWorktree = Worktree( 5296 + id: Repository.folderWorktreeID(for: folderURL), 5297 + name: Repository.name(for: folderURL), 5298 + detail: "", 5299 + workingDirectory: folderURL, 5300 + repositoryRootURL: folderURL 5301 + ) 5302 + let folderRepo = Repository( 5303 + id: folderRoot, 5304 + rootURL: folderURL, 5305 + name: Repository.name(for: folderURL), 5306 + worktrees: IdentifiedArray(uniqueElements: [folderWorktree]), 5307 + isGitRepository: false 5308 + ) 5309 + 5310 + var state = RepositoriesFeature.State() 5311 + state.repositories = [folderRepo] 5312 + state.repositoryRoots = [folderURL] 5313 + state.isInitialLoadComplete = true 5314 + @Shared(.repositorySettings(folderURL)) var repositorySettings 5315 + $repositorySettings.withLock { $0.deleteScript = "echo goodbye" } 5316 + defer { $repositorySettings.withLock { $0.deleteScript = "" } } 5317 + 5318 + // Intent + batch are normally recorded by the alert handler 5319 + // before `.deleteSidebarItemConfirmed` runs — seed them here 5320 + // since the test dispatches the action directly. 5321 + state.seedRemovalBatch(pending: [folderRepo.id: .folderUnlink]) 5322 + 5323 + let store = TestStore(initialState: state) { 5324 + RepositoriesFeature() 5325 + } withDependencies: { 5326 + $0.repositoryPersistence.loadRoots = { [folderRoot] } 5327 + $0.repositoryPersistence.saveRoots = { _ in } 5328 + $0.gitClient.isGitRepository = { _ in false } 5329 + $0.gitClient.worktrees = { _ in [] } 5330 + $0.gitClient.removeWorktree = { _, _ in 5331 + Issue.record("removeWorktree must not be called for a folder repository") 5332 + return folderURL 5333 + } 5334 + $0.analyticsClient.capture = { _, _ in } 5335 + } 5336 + store.exhaustivity = .off(showSkippedAssertions: false) 5337 + 5338 + await store.send(.deleteSidebarItemConfirmed(folderWorktree.id, folderRepo.id)) 5339 + await store.skipReceivedActions() 5340 + await store.send( 5341 + .deleteScriptCompleted(worktreeID: folderWorktree.id, exitCode: 0, tabId: nil) 5342 + ) 5343 + await store.skipReceivedActions() 5344 + #expect(store.state.repositories.isEmpty) 5345 + #expect(store.state.removingRepositoryIDs[folderRepo.id] == nil) 5346 + } 5347 + 5348 + @Test func folderDeleteScriptRunningKeepsRowClickableWithTerminalIndicator() { 5349 + // While a folder's delete script is running, the sidebar row 5350 + // must stay clickable (so the user can view the script output) 5351 + // and show the terminal-backed deleting status — matching the 5352 + // regular worktree delete flow. `removingRepositoryIDs` is set 5353 + // upfront to carry folder intent, so the status + removing 5354 + // checks must give the terminal indicator priority. 5355 + let folderRoot = "/tmp/\(UUID().uuidString)-folder" 5356 + let folderURL = URL(fileURLWithPath: folderRoot) 5357 + let folderWorktree = Worktree( 5358 + id: Repository.folderWorktreeID(for: folderURL), 5359 + name: Repository.name(for: folderURL), 5360 + detail: "", 5361 + workingDirectory: folderURL, 5362 + repositoryRootURL: folderURL 5363 + ) 5364 + let folderRepo = Repository( 5365 + id: folderRoot, 5366 + rootURL: folderURL, 5367 + name: Repository.name(for: folderURL), 5368 + worktrees: IdentifiedArray(uniqueElements: [folderWorktree]), 5369 + isGitRepository: false 5370 + ) 5371 + 5372 + var state = RepositoriesFeature.State() 5373 + state.repositories = [folderRepo] 5374 + state.repositoryRoots = [folderURL] 5375 + state.isInitialLoadComplete = true 5376 + state.seedRemovalBatch(pending: [folderRepo.id: .folderUnlink]) 5377 + state.deleteScriptWorktreeIDs.insert(folderWorktree.id) 5378 + 5379 + #expect(state.isRemovingRepository(folderRepo) == false) 5380 + let rows = state.sidebarItems(in: folderRepo) 5381 + #expect(rows.first?.status == .deleting(inTerminal: true)) 5382 + #expect(rows.first?.kind == .folder) 5383 + } 5384 + 5385 + @Test func deleteWorktreeScriptFailureForFolderClearsRemovingState() async { 5386 + // Script failure during folder deletion surfaces the standard 5387 + // alert AND rolls back `removingRepositoryIDs` so the sidebar 5388 + // row returns to its normal enabled state. The folder must stay 5389 + // in `state.repositories` — nothing is removed. 5390 + let folderRoot = "/tmp/\(UUID().uuidString)-folder" 5391 + let folderURL = URL(fileURLWithPath: folderRoot) 5392 + let folderWorktree = Worktree( 5393 + id: Repository.folderWorktreeID(for: folderURL), 5394 + name: Repository.name(for: folderURL), 5395 + detail: "", 5396 + workingDirectory: folderURL, 5397 + repositoryRootURL: folderURL 5398 + ) 5399 + let folderRepo = Repository( 5400 + id: folderRoot, 5401 + rootURL: folderURL, 5402 + name: Repository.name(for: folderURL), 5403 + worktrees: IdentifiedArray(uniqueElements: [folderWorktree]), 5404 + isGitRepository: false 5405 + ) 5406 + 5407 + var state = RepositoriesFeature.State() 5408 + state.repositories = [folderRepo] 5409 + state.repositoryRoots = [folderURL] 5410 + state.isInitialLoadComplete = true 5411 + state.deleteScriptWorktreeIDs.insert(folderWorktree.id) 5412 + state.seedRemovalBatch(pending: [folderRepo.id: .folderUnlink]) 5413 + 5414 + let store = TestStore(initialState: state) { 5415 + RepositoriesFeature() 5416 + } 5417 + store.exhaustivity = .off(showSkippedAssertions: false) 5418 + 5419 + await store.send( 5420 + .deleteScriptCompleted(worktreeID: folderWorktree.id, exitCode: 2, tabId: nil) 5421 + ) 5422 + await store.skipReceivedActions() 5423 + // Alert is shown for the failure; batch drains without firing a 5424 + // `.repositoriesRemoved` because there were no successes. 5425 + #expect(store.state.alert != nil) 5426 + #expect(store.state.removingRepositoryIDs[folderRepo.id] == nil) 5427 + #expect(store.state.deleteScriptWorktreeIDs.isEmpty) 5428 + #expect(store.state.repositories.count == 1) 5429 + #expect(store.state.activeRemovalBatches.isEmpty) 5430 + } 5431 + 5432 + @Test func deleteScriptCompletedForFolderKindFlipShowsErrorAndStops() async { 5433 + // If a `git init` flips the classification between the alert 5434 + // confirmation and the delete-script completion, the handler 5435 + // surfaces an explicit error and aborts — safer than silently 5436 + // trashing the directory or running `gitClient.removeWorktree`. 5437 + let folderRoot = "/tmp/\(UUID().uuidString)-folder" 5438 + let folderURL = URL(fileURLWithPath: folderRoot) 5439 + let folderWorktree = Worktree( 5440 + id: Repository.folderWorktreeID(for: folderURL), 5441 + name: Repository.name(for: folderURL), 5442 + detail: "", 5443 + workingDirectory: folderURL, 5444 + repositoryRootURL: folderURL 5445 + ) 5446 + let flippedRepo = Repository( 5447 + id: folderRoot, 5448 + rootURL: folderURL, 5449 + name: Repository.name(for: folderURL), 5450 + worktrees: IdentifiedArray(uniqueElements: [folderWorktree]), 5451 + isGitRepository: true 5452 + ) 5453 + 5454 + var state = RepositoriesFeature.State() 5455 + state.repositories = [flippedRepo] 5456 + state.repositoryRoots = [folderURL] 5457 + state.isInitialLoadComplete = true 5458 + state.deleteScriptWorktreeIDs.insert(folderWorktree.id) 5459 + state.seedRemovalBatch(pending: [flippedRepo.id: .folderTrash]) 5460 + 5461 + let store = TestStore(initialState: state) { 5462 + RepositoriesFeature() 5463 + } withDependencies: { 5464 + $0.gitClient.removeWorktree = { _, _ in 5465 + Issue.record("removeWorktree must not run on kind-flip abort") 5466 + return folderURL 5467 + } 5468 + } 5469 + store.exhaustivity = .off(showSkippedAssertions: false) 5470 + 5471 + await store.send( 5472 + .deleteScriptCompleted(worktreeID: folderWorktree.id, exitCode: 0, tabId: nil) 5473 + ) 5474 + await store.skipReceivedActions() 5475 + // Kind flip aborts the removal; the folder stays in state and 5476 + // the alert explains the decision. 5477 + #expect(store.state.alert != nil) 5478 + #expect(store.state.removingRepositoryIDs[flippedRepo.id] == nil) 5479 + #expect(store.state.repositories.count == 1) 5480 + } 5481 + 5482 + @Test func createRandomWorktreeInRepositoryRejectsFolderRepositories() async { 5483 + // Hotkey / palette / deeplink can all target a folder; the 5484 + // reducer must stop the action up front with an alert rather 5485 + // than sending it into `gitClient.createWorktreeStream`. 5486 + let folderRoot = "/tmp/\(UUID().uuidString)-folder" 5487 + let folderURL = URL(fileURLWithPath: folderRoot) 5488 + let folderWorktree = Worktree( 5489 + id: Repository.folderWorktreeID(for: folderURL), 5490 + name: Repository.name(for: folderURL), 5491 + detail: "", 5492 + workingDirectory: folderURL, 5493 + repositoryRootURL: folderURL 5494 + ) 5495 + let folderRepo = Repository( 5496 + id: folderRoot, 5497 + rootURL: folderURL, 5498 + name: Repository.name(for: folderURL), 5499 + worktrees: IdentifiedArray(uniqueElements: [folderWorktree]), 5500 + isGitRepository: false 5501 + ) 5502 + 5503 + var state = RepositoriesFeature.State() 5504 + state.repositories = [folderRepo] 5505 + state.repositoryRoots = [folderURL] 5506 + state.isInitialLoadComplete = true 5507 + 5508 + let store = TestStore(initialState: state) { 5509 + RepositoriesFeature() 5510 + } withDependencies: { 5511 + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in 5512 + AsyncThrowingStream { continuation in 5513 + Issue.record("createWorktreeStream must not run for folder repositories") 5514 + continuation.finish() 5515 + } 5516 + } 5517 + } 5518 + 5519 + await store.send(.createRandomWorktreeInRepository(folderRepo.id)) { 5520 + $0.alert = AlertState { 5521 + TextState("Unable to create worktree") 5522 + } actions: { 5523 + ButtonState(role: .cancel) { 5524 + TextState("OK") 5525 + } 5526 + } message: { 5527 + TextState("Worktrees are only supported for git repositories.") 5528 + } 5529 + } 5530 + } 5531 + 5532 + @Test func deleteScriptCancellationForFolderClearsRemovingState() async { 5533 + // Cancelling the delete-script tab (exitCode: nil) must also 5534 + // release `removingRepositoryIDs` — otherwise the folder row 5535 + // stays visually "removing" forever. 5536 + let folderRoot = "/tmp/\(UUID().uuidString)-folder" 5537 + let folderURL = URL(fileURLWithPath: folderRoot) 5538 + let folderWorktree = Worktree( 5539 + id: Repository.folderWorktreeID(for: folderURL), 5540 + name: Repository.name(for: folderURL), 5541 + detail: "", 5542 + workingDirectory: folderURL, 5543 + repositoryRootURL: folderURL 5544 + ) 5545 + let folderRepo = Repository( 5546 + id: folderRoot, 5547 + rootURL: folderURL, 5548 + name: Repository.name(for: folderURL), 5549 + worktrees: IdentifiedArray(uniqueElements: [folderWorktree]), 5550 + isGitRepository: false 5551 + ) 5552 + 5553 + var state = RepositoriesFeature.State() 5554 + state.repositories = [folderRepo] 5555 + state.repositoryRoots = [folderURL] 5556 + state.isInitialLoadComplete = true 5557 + state.deleteScriptWorktreeIDs.insert(folderWorktree.id) 5558 + state.seedRemovalBatch(pending: [folderRepo.id: .folderUnlink]) 5559 + 5560 + let store = TestStore(initialState: state) { 5561 + RepositoriesFeature() 5562 + } 5563 + store.exhaustivity = .off(showSkippedAssertions: false) 5564 + 5565 + await store.send( 5566 + .deleteScriptCompleted(worktreeID: folderWorktree.id, exitCode: nil, tabId: nil) 5567 + ) 5568 + await store.skipReceivedActions() 5569 + #expect(store.state.deleteScriptWorktreeIDs.isEmpty) 5570 + #expect(store.state.removingRepositoryIDs[folderRepo.id] == nil) 5571 + #expect(store.state.repositories.count == 1) 5572 + } 5573 + 5574 + @Test func confirmDeleteSidebarItemDeleteActionTrashesFolderAfterRemoval() async throws { 5575 + // `.confirmDeleteSidebarItems([folder target], disposition: .folderTrash)` 5576 + // records the `.folderTrash` intent and forwards to 5577 + // `.deleteSidebarItemConfirmed`. On an empty delete script the 5578 + // flow finishes by moving the directory to the Trash (via 5579 + // `FileManager.trashItem`) and then signaling 5580 + // `.repositoryRemovalCompleted`, which the batch aggregator 5581 + // drains into `.repositoriesRemoved`. 5582 + let tempDir = FileManager.default.temporaryDirectory 5583 + .appending(path: "supa-\(UUID().uuidString)-folder", directoryHint: .isDirectory) 5584 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 5585 + defer { try? FileManager.default.removeItem(at: tempDir) } 5586 + let standardized = tempDir.standardizedFileURL 5587 + let rootID = standardized.path(percentEncoded: false) 5588 + let folderWorktree = Worktree( 5589 + id: Repository.folderWorktreeID(for: standardized), 5590 + name: Repository.name(for: standardized), 5591 + detail: "", 5592 + workingDirectory: standardized, 5593 + repositoryRootURL: standardized 5594 + ) 5595 + let folderRepo = Repository( 5596 + id: rootID, 5597 + rootURL: standardized, 5598 + name: Repository.name(for: standardized), 5599 + worktrees: IdentifiedArray(uniqueElements: [folderWorktree]), 5600 + isGitRepository: false 5601 + ) 5602 + 5603 + var state = RepositoriesFeature.State() 5604 + state.repositories = [folderRepo] 5605 + state.repositoryRoots = [standardized] 5606 + state.isInitialLoadComplete = true 5607 + let folderTarget = RepositoriesFeature.DeleteWorktreeTarget( 5608 + worktreeID: folderWorktree.id, repositoryID: folderRepo.id) 5609 + state.alert = AlertState { 5610 + TextState("Remove folder?") 5611 + } actions: { 5612 + ButtonState( 5613 + action: .confirmDeleteSidebarItems([folderTarget], disposition: .folderUnlink) 5614 + ) { 5615 + TextState("Remove from Supacode") 5616 + } 5617 + ButtonState( 5618 + role: .destructive, 5619 + action: .confirmDeleteSidebarItems([folderTarget], disposition: .folderTrash) 5620 + ) { 5621 + TextState("Delete from disk") 5622 + } 5623 + ButtonState(role: .cancel) { 5624 + TextState("Cancel") 5625 + } 5626 + } message: { 5627 + TextState("Remove \(folderWorktree.name)?") 5628 + } 5629 + 5630 + let store = TestStore(initialState: state) { 5631 + RepositoriesFeature() 5632 + } withDependencies: { 5633 + $0.repositoryPersistence.loadRoots = { [] } 5634 + $0.repositoryPersistence.saveRoots = { _ in } 5635 + $0.gitClient.isGitRepository = { _ in false } 5636 + $0.gitClient.worktrees = { _ in [] } 5637 + $0.analyticsClient.capture = { _, _ in } 5638 + $0.uuid = .incrementing 5639 + } 5640 + store.exhaustivity = .off(showSkippedAssertions: false) 5641 + 5642 + await store.send( 5643 + .alert(.presented(.confirmDeleteSidebarItems([folderTarget], disposition: .folderTrash))) 5644 + ) 5645 + await store.skipReceivedActions() 5646 + 5647 + // The trash effect ran and moved the directory away (or logged 5648 + // a warning if trashItem refused). Either way the folder must no 5649 + // longer live at its original path. 5650 + #expect(!FileManager.default.fileExists(atPath: standardized.path(percentEncoded: false))) 5651 + } 5652 + 5653 + @Test func folderTrashFailureSurfacesAlertAndKeepsRepo() async { 5654 + // F2: `folderRemovalEffect` used to always dispatch 5655 + // `succeeded: true` on `FileManager.trashItem` failure, silently 5656 + // making the folder disappear from Supacode even though its 5657 + // on-disk contents stayed put. Fix dispatches `succeeded: false` 5658 + // AND surfaces a "Delete from disk failed" alert so the user 5659 + // knows what happened. 5660 + let missingRoot = "/tmp/supacode-missing-\(UUID().uuidString)" 5661 + let missingURL = URL(fileURLWithPath: missingRoot) 5662 + let rootID = missingURL.standardizedFileURL.path(percentEncoded: false) 5663 + let folderWorktree = Worktree( 5664 + id: Repository.folderWorktreeID(for: missingURL), 5665 + name: Repository.name(for: missingURL), detail: "", 5666 + workingDirectory: missingURL, repositoryRootURL: missingURL 5667 + ) 5668 + let folderRepo = Repository( 5669 + id: rootID, rootURL: missingURL, name: Repository.name(for: missingURL), 5670 + worktrees: IdentifiedArray(uniqueElements: [folderWorktree]), 5671 + isGitRepository: false 5672 + ) 5673 + 5674 + var state = RepositoriesFeature.State() 5675 + state.repositories = [folderRepo] 5676 + state.repositoryRoots = [missingURL] 5677 + state.isInitialLoadComplete = true 5678 + let folderTarget = RepositoriesFeature.DeleteWorktreeTarget( 5679 + worktreeID: folderWorktree.id, repositoryID: folderRepo.id) 5680 + 5681 + let store = TestStore(initialState: state) { 5682 + RepositoriesFeature() 5683 + } withDependencies: { 5684 + $0.repositoryPersistence.loadRoots = { [] } 5685 + $0.repositoryPersistence.saveRoots = { _ in } 5686 + $0.gitClient.isGitRepository = { _ in false } 5687 + $0.gitClient.worktrees = { _ in [] } 5688 + $0.analyticsClient.capture = { _, _ in } 5689 + $0.uuid = .incrementing 5690 + } 5691 + store.exhaustivity = .off(showSkippedAssertions: false) 5692 + 5693 + await store.send( 5694 + .alert(.presented(.confirmDeleteSidebarItems([folderTarget], disposition: .folderTrash))) 5695 + ) 5696 + await store.skipReceivedActions() 5697 + 5698 + #expect(store.state.alert != nil, "trash failure must surface an alert") 5699 + #expect( 5700 + store.state.repositories.contains(where: { $0.id == folderRepo.id }), 5701 + "folder must remain in state when trash fails" 5702 + ) 5703 + #expect( 5704 + store.state.removingRepositoryIDs[folderRepo.id] == nil, 5705 + "removing indicator must clear on failure" 5706 + ) 5707 + // Regression: trash failure used to leave `deletingWorktreeIDs` 5708 + // populated (seeded by the empty-script folder branch), so the 5709 + // sidebar row rendered `.deleting(inTerminal: false)` forever. 5710 + // The failure path now clears per-worktree trackers too. 5711 + #expect( 5712 + !store.state.deletingWorktreeIDs.contains(folderWorktree.id), 5713 + "deletingWorktreeIDs must clear on trash failure" 5714 + ) 5715 + #expect( 5716 + !store.state.deleteScriptWorktreeIDs.contains(folderWorktree.id), 5717 + "deleteScriptWorktreeIDs must clear on trash failure" 5718 + ) 5719 + #expect(store.state.activeRemovalBatches.isEmpty) 5720 + } 5721 + 5722 + @Test func bulkFolderTrashFailuresCoalesceIntoSingleAlert() async { 5723 + // C3 regression: parallel per-target `FileManager.trashItem` 5724 + // failures used to each fire `.presentAlert` and clobber 5725 + // `state.alert` in a last-write-wins race. The batch aggregator 5726 + // now collects per-target `failureMessage`s and surfaces one 5727 + // consolidated alert naming every failed folder when the batch 5728 + // drains. 5729 + let rootA = "/tmp/missing-trash-\(UUID().uuidString)-a" 5730 + let rootB = "/tmp/missing-trash-\(UUID().uuidString)-b" 5731 + let urlA = URL(fileURLWithPath: rootA) 5732 + let urlB = URL(fileURLWithPath: rootB) 5733 + func makeFolderRepo(url: URL, id: String) -> (Worktree, Repository) { 5734 + let worktree = Worktree( 5735 + id: Repository.folderWorktreeID(for: url), 5736 + name: Repository.name(for: url), detail: "", 5737 + workingDirectory: url, repositoryRootURL: url 5738 + ) 5739 + let repo = Repository( 5740 + id: id, rootURL: url, name: Repository.name(for: url), 5741 + worktrees: IdentifiedArray(uniqueElements: [worktree]), 5742 + isGitRepository: false 5743 + ) 5744 + return (worktree, repo) 5745 + } 5746 + let (worktreeA, folderA) = makeFolderRepo(url: urlA, id: rootA) 5747 + let (worktreeB, folderB) = makeFolderRepo(url: urlB, id: rootB) 5748 + 5749 + var state = RepositoriesFeature.State() 5750 + state.repositories = [folderA, folderB] 5751 + state.repositoryRoots = [urlA, urlB] 5752 + state.isInitialLoadComplete = true 5753 + 5754 + let store = TestStore(initialState: state) { 5755 + RepositoriesFeature() 5756 + } withDependencies: { 5757 + $0.repositoryPersistence.loadRoots = { [] } 5758 + $0.repositoryPersistence.saveRoots = { _ in } 5759 + $0.repositoryPersistence.pruneRepositoryConfigs = { _ in } 5760 + $0.gitClient.isGitRepository = { _ in false } 5761 + $0.gitClient.worktrees = { _ in [] } 5762 + $0.analyticsClient.capture = { _, _ in } 5763 + $0.uuid = .incrementing 5764 + } 5765 + store.exhaustivity = .off(showSkippedAssertions: false) 5766 + 5767 + let targets = [ 5768 + RepositoriesFeature.DeleteWorktreeTarget(worktreeID: worktreeA.id, repositoryID: folderA.id), 5769 + RepositoriesFeature.DeleteWorktreeTarget(worktreeID: worktreeB.id, repositoryID: folderB.id), 5770 + ] 5771 + await store.send( 5772 + .alert(.presented(.confirmDeleteSidebarItems(targets, disposition: .folderTrash))) 5773 + ) 5774 + await store.skipReceivedActions() 5775 + 5776 + // Both folders stay (trash failed), and the alert mentions BOTH 5777 + // folder names — not just the last one. 5778 + #expect(store.state.repositories.count == 2) 5779 + #expect(store.state.activeRemovalBatches.isEmpty) 5780 + #expect(store.state.removingRepositoryIDs.isEmpty) 5781 + guard let alert = store.state.alert else { 5782 + Issue.record("Expected consolidated trash-failure alert") 5783 + return 5784 + } 5785 + let titleText = String(describing: alert.title) 5786 + let messageText = String(describing: alert.message ?? TextState("")) 5787 + #expect(titleText.contains("Delete from disk failed")) 5788 + #expect( 5789 + messageText.contains(folderA.name) && messageText.contains(folderB.name), 5790 + "consolidated alert must name every failed folder (both \(folderA.name) and \(folderB.name))" 5791 + ) 5792 + } 5793 + 5794 + @Test func deleteSidebarItemConfirmedDoesNotClobberTerminalAlert() async { 5795 + // Pass-3 F1 regression: `.deleteSidebarItemConfirmed` used to 5796 + // unconditionally clear `state.alert`. The alert-confirm path 5797 + // already clears the alert at `.confirmDeleteSidebarItems` 5798 + // entry, so the only effect of the second clear was to wipe 5799 + // unrelated alerts dispatched programmatically (e.g., the 5800 + // consolidated trash-failure alert set by the batch aggregator 5801 + // just before the auto-delete sweep fires 5802 + // `.deleteSidebarItemConfirmed` for an expired archived git 5803 + // worktree). 5804 + let gitRoot = "/tmp/alert-clobber-\(UUID().uuidString)-repo" 5805 + let gitURL = URL(fileURLWithPath: gitRoot) 5806 + let worktree = Worktree( 5807 + id: "\(gitRoot)/wt-1", 5808 + name: "wt-1", 5809 + detail: "", 5810 + workingDirectory: URL(fileURLWithPath: "\(gitRoot)/wt-1"), 5811 + repositoryRootURL: gitURL 5812 + ) 5813 + let mainWorktree = Worktree( 5814 + id: gitRoot, 5815 + name: "repo", 5816 + detail: "", 5817 + workingDirectory: gitURL, 5818 + repositoryRootURL: gitURL 5819 + ) 5820 + let gitRepo = Repository( 5821 + id: gitRoot, rootURL: gitURL, name: "repo", 5822 + worktrees: IdentifiedArray(uniqueElements: [mainWorktree, worktree]), 5823 + isGitRepository: true 5824 + ) 5825 + 5826 + let sentinelAlert = AlertState<RepositoriesFeature.Alert> { 5827 + TextState("Do not wipe me") 5828 + } actions: { 5829 + ButtonState(role: .cancel) { TextState("OK") } 5830 + } message: { 5831 + TextState("Terminal failure alert from the aggregator.") 5832 + } 5833 + var state = RepositoriesFeature.State() 5834 + state.repositories = [gitRepo] 5835 + state.repositoryRoots = [gitURL] 5836 + state.isInitialLoadComplete = true 5837 + state.alert = sentinelAlert 5838 + 5839 + let store = TestStore(initialState: state) { 5840 + RepositoriesFeature() 5841 + } withDependencies: { 5842 + $0.repositoryPersistence.loadRoots = { [gitRoot] } 5843 + $0.repositoryPersistence.saveRoots = { _ in } 5844 + $0.repositoryPersistence.pruneRepositoryConfigs = { _ in } 5845 + $0.gitClient.isGitRepository = { _ in true } 5846 + $0.gitClient.worktrees = { _ in [] } 5847 + $0.gitClient.removeWorktree = { _, _ in 5848 + URL(fileURLWithPath: "\(gitRoot)/wt-1") 5849 + } 5850 + $0.analyticsClient.capture = { _, _ in } 5851 + } 5852 + store.exhaustivity = .off(showSkippedAssertions: false) 5853 + 5854 + // Programmatic `.deleteSidebarItemConfirmed` — the code path 5855 + // that `.autoDeleteExpiredArchivedWorktrees` uses. 5856 + await store.send(.deleteSidebarItemConfirmed(worktree.id, gitRepo.id)) 5857 + await store.skipReceivedActions() 5858 + 5859 + #expect( 5860 + store.state.alert == sentinelAlert, 5861 + "terminal alerts must survive a programmatic .deleteSidebarItemConfirmed" 5862 + ) 5863 + } 5864 + 5865 + @Test func deleteScriptCompletedDrainsBatchWhenOwningRepoVanished() async { 5866 + // C4 regression: if the owning repo got pruned from 5867 + // `state.repositories` between confirmation and script 5868 + // completion (concurrent reload, `.removeFailedRepository`, 5869 + // file-system observer race, etc.), the exit=0 branch used to 5870 + // fall into the generic "Delete failed / not found" alert and 5871 + // return `.none` — leaving the `removingRepositoryIDs` record 5872 + // and `activeRemovalBatches` entry orphaned, so sibling folders 5873 + // in the same batch hung forever. 5874 + // 5875 + // Reproduces by seeding the batch + record but NOT adding the 5876 + // repo to `state.repositories`, then firing exit=0. 5877 + let folderRoot = "/tmp/vanished-\(UUID().uuidString)-folder" 5878 + let folderURL = URL(fileURLWithPath: folderRoot) 5879 + let folderWorktreeID = Repository.folderWorktreeID(for: folderURL) 5880 + 5881 + var state = RepositoriesFeature.State() 5882 + // Intentionally empty — simulating the repo vanishing mid-script. 5883 + state.repositories = [] 5884 + state.repositoryRoots = [] 5885 + state.isInitialLoadComplete = true 5886 + state.deleteScriptWorktreeIDs.insert(folderWorktreeID) 5887 + let batchID = state.seedRemovalBatch(pending: [folderRoot: .folderUnlink]) 5888 + 5889 + let store = TestStore(initialState: state) { 5890 + RepositoriesFeature() 5891 + } withDependencies: { 5892 + $0.repositoryPersistence.loadRoots = { [] } 5893 + $0.repositoryPersistence.saveRoots = { _ in } 5894 + $0.repositoryPersistence.pruneRepositoryConfigs = { _ in } 5895 + $0.gitClient.isGitRepository = { _ in false } 5896 + $0.gitClient.worktrees = { _ in [] } 5897 + $0.analyticsClient.capture = { _, _ in } 5898 + } 5899 + store.exhaustivity = .off(showSkippedAssertions: false) 5900 + 5901 + await store.send( 5902 + .deleteScriptCompleted(worktreeID: folderWorktreeID, exitCode: 0, tabId: nil) 5903 + ) 5904 + await store.skipReceivedActions() 5905 + 5906 + #expect( 5907 + store.state.removingRepositoryIDs[folderRoot] == nil, 5908 + "record must drain even when owning repo vanished mid-script" 5909 + ) 5910 + #expect( 5911 + store.state.activeRemovalBatches[batchID] == nil, 5912 + "batch must drain (succeeded:false) so sibling targets don't hang" 5913 + ) 5914 + #expect(!store.state.deleteScriptWorktreeIDs.contains(folderWorktreeID)) 5915 + } 5916 + 5917 + @Test func bulkFolderUnlinkTerminatesWithEmptyState() async { 5918 + // Regression: per-target `.repositoryRemoved` chaining used to 5919 + // race `cancelInFlight: true` on the persistence save, leaving 5920 + // only the first folder actually removed. The batch aggregator 5921 + // now fires one terminal `.repositoriesRemoved([ids])` after 5922 + // every target signals completion — bulk unlink must end with 5923 + // `state.repositories.isEmpty` and the batch drained. 5924 + let rootA = "/tmp/\(UUID().uuidString)-folder-a" 5925 + let rootB = "/tmp/\(UUID().uuidString)-folder-b" 5926 + let rootC = "/tmp/\(UUID().uuidString)-folder-c" 5927 + let urlA = URL(fileURLWithPath: rootA) 5928 + let urlB = URL(fileURLWithPath: rootB) 5929 + let urlC = URL(fileURLWithPath: rootC) 5930 + func makeFolderRepo(url: URL, id: String) -> (Worktree, Repository) { 5931 + let worktree = Worktree( 5932 + id: Repository.folderWorktreeID(for: url), 5933 + name: Repository.name(for: url), 5934 + detail: "", 5935 + workingDirectory: url, 5936 + repositoryRootURL: url 5937 + ) 5938 + let repo = Repository( 5939 + id: id, rootURL: url, name: Repository.name(for: url), 5940 + worktrees: IdentifiedArray(uniqueElements: [worktree]), isGitRepository: false) 5941 + return (worktree, repo) 5942 + } 5943 + let (worktreeA, folderA) = makeFolderRepo(url: urlA, id: rootA) 5944 + let (worktreeB, folderB) = makeFolderRepo(url: urlB, id: rootB) 5945 + let (worktreeC, folderC) = makeFolderRepo(url: urlC, id: rootC) 5946 + 5947 + var state = RepositoriesFeature.State() 5948 + state.repositories = [folderA, folderB, folderC] 5949 + state.repositoryRoots = [urlA, urlB, urlC] 5950 + state.isInitialLoadComplete = true 5951 + 5952 + let store = TestStore(initialState: state) { 5953 + RepositoriesFeature() 5954 + } withDependencies: { 5955 + $0.repositoryPersistence.loadRoots = { [] } 5956 + $0.repositoryPersistence.saveRoots = { _ in } 5957 + $0.gitClient.isGitRepository = { _ in false } 5958 + $0.gitClient.worktrees = { _ in [] } 5959 + $0.analyticsClient.capture = { _, _ in } 5960 + $0.uuid = .incrementing 5961 + } 5962 + store.exhaustivity = .off(showSkippedAssertions: false) 5963 + 5964 + let targets = [ 5965 + RepositoriesFeature.DeleteWorktreeTarget(worktreeID: worktreeA.id, repositoryID: folderA.id), 5966 + RepositoriesFeature.DeleteWorktreeTarget(worktreeID: worktreeB.id, repositoryID: folderB.id), 5967 + RepositoriesFeature.DeleteWorktreeTarget(worktreeID: worktreeC.id, repositoryID: folderC.id), 5968 + ] 5969 + await store.send( 5970 + .alert(.presented(.confirmDeleteSidebarItems(targets, disposition: .folderUnlink))) 5971 + ) 5972 + await store.skipReceivedActions() 5973 + 5974 + #expect(store.state.repositories.isEmpty) 5975 + #expect(store.state.repositoryRoots.isEmpty) 5976 + #expect(store.state.removingRepositoryIDs.isEmpty) 5977 + #expect(store.state.activeRemovalBatches.isEmpty) 5978 + } 5979 + 5980 + @Test func folderRemovalPrunesRootsAndConfigsFromSettings() async { 5981 + // Regression: the `.repositoriesRemoved` terminal must write the 5982 + // pruned list to `settings.json` AND drop the per-repo config 5983 + // entry from `settingsFile.repositories`. The latter half used 5984 + // to leak forever — users who added and removed folders for 5985 + // testing saw stale entries pile up in the JSON. 5986 + let rootA = "/tmp/\(UUID().uuidString)-folder-a" 5987 + let rootB = "/tmp/\(UUID().uuidString)-folder-b" 5988 + let urlA = URL(fileURLWithPath: rootA).standardizedFileURL 5989 + let urlB = URL(fileURLWithPath: rootB).standardizedFileURL 5990 + let idA = urlA.path(percentEncoded: false) 5991 + let idB = urlB.path(percentEncoded: false) 5992 + let worktreeA = Worktree( 5993 + id: Repository.folderWorktreeID(for: urlA), 5994 + name: Repository.name(for: urlA), detail: "", 5995 + workingDirectory: urlA, repositoryRootURL: urlA 5996 + ) 5997 + let folderA = Repository( 5998 + id: idA, rootURL: urlA, name: Repository.name(for: urlA), 5999 + worktrees: IdentifiedArray(uniqueElements: [worktreeA]), 6000 + isGitRepository: false 6001 + ) 6002 + let worktreeB = Worktree( 6003 + id: Repository.folderWorktreeID(for: urlB), 6004 + name: Repository.name(for: urlB), detail: "", 6005 + workingDirectory: urlB, repositoryRootURL: urlB 6006 + ) 6007 + let folderB = Repository( 6008 + id: idB, rootURL: urlB, name: Repository.name(for: urlB), 6009 + worktrees: IdentifiedArray(uniqueElements: [worktreeB]), 6010 + isGitRepository: false 6011 + ) 6012 + 6013 + var state = RepositoriesFeature.State() 6014 + state.repositories = [folderA, folderB] 6015 + state.repositoryRoots = [urlA, urlB] 6016 + state.isInitialLoadComplete = true 6017 + 6018 + let savedPaths = LockIsolated<[[String]]>([]) 6019 + let prunedIDs = LockIsolated<[[String]]>([]) 6020 + let store = TestStore(initialState: state) { 6021 + RepositoriesFeature() 6022 + } withDependencies: { 6023 + $0.repositoryPersistence.loadRoots = { [idA, idB] } 6024 + $0.repositoryPersistence.saveRoots = { paths in 6025 + savedPaths.withValue { $0.append(paths) } 6026 + } 6027 + $0.repositoryPersistence.pruneRepositoryConfigs = { ids in 6028 + prunedIDs.withValue { $0.append(ids) } 6029 + } 6030 + $0.gitClient.isGitRepository = { _ in false } 6031 + $0.gitClient.worktrees = { _ in [] } 6032 + $0.analyticsClient.capture = { _, _ in } 6033 + $0.uuid = .incrementing 6034 + } 6035 + store.exhaustivity = .off(showSkippedAssertions: false) 6036 + 6037 + let targetA = RepositoriesFeature.DeleteWorktreeTarget( 6038 + worktreeID: worktreeA.id, repositoryID: folderA.id) 6039 + await store.send( 6040 + .alert(.presented(.confirmDeleteSidebarItems([targetA], disposition: .folderUnlink))) 6041 + ) 6042 + await store.skipReceivedActions() 6043 + 6044 + #expect(savedPaths.value.last == [idB], "saveRoots must persist the pruned root list") 6045 + #expect( 6046 + prunedIDs.value.flatMap { $0 } == [idA], 6047 + "pruneRepositoryConfigs must drop the removed repo's config entry" 6048 + ) 6049 + #expect(store.state.repositories.map(\.id) == [idB]) 6050 + #expect(store.state.repositoryRoots.map { $0.path(percentEncoded: false) } == [idB]) 6051 + } 6052 + 6053 + @Test func requestDeleteSidebarItemsShowsFolderAlertAndFanOutsForAllFolderBulk() async { 6054 + // `.requestDeleteSidebarItems` is the single entry point for bulk 6055 + // remove — it uses the target repos' kind as a discriminator to 6056 + // decide whether to show the worktree-style alert or the 6057 + // folder-style 3-button alert. All-folder bulk confirms fan out 6058 + // through `.deleteSidebarItemConfirmed` so each folder reuses the 6059 + // single-folder delete-script pipeline. 6060 + let rootA = "/tmp/\(UUID().uuidString)-folder-a" 6061 + let rootB = "/tmp/\(UUID().uuidString)-folder-b" 6062 + let urlA = URL(fileURLWithPath: rootA) 6063 + let urlB = URL(fileURLWithPath: rootB) 6064 + let worktreeA = Worktree( 6065 + id: Repository.folderWorktreeID(for: urlA), 6066 + name: Repository.name(for: urlA), 6067 + detail: "", 6068 + workingDirectory: urlA, 6069 + repositoryRootURL: urlA 6070 + ) 6071 + let worktreeB = Worktree( 6072 + id: Repository.folderWorktreeID(for: urlB), 6073 + name: Repository.name(for: urlB), 6074 + detail: "", 6075 + workingDirectory: urlB, 6076 + repositoryRootURL: urlB 6077 + ) 6078 + let folderA = Repository( 6079 + id: rootA, 6080 + rootURL: urlA, 6081 + name: Repository.name(for: urlA), 6082 + worktrees: IdentifiedArray(uniqueElements: [worktreeA]), 6083 + isGitRepository: false 6084 + ) 6085 + let folderB = Repository( 6086 + id: rootB, 6087 + rootURL: urlB, 6088 + name: Repository.name(for: urlB), 6089 + worktrees: IdentifiedArray(uniqueElements: [worktreeB]), 6090 + isGitRepository: false 6091 + ) 6092 + 6093 + var state = RepositoriesFeature.State() 6094 + state.repositories = [folderA, folderB] 6095 + state.repositoryRoots = [urlA, urlB] 6096 + state.isInitialLoadComplete = true 6097 + 6098 + let store = TestStore(initialState: state) { 6099 + RepositoriesFeature() 6100 + } withDependencies: { 6101 + $0.repositoryPersistence.loadRoots = { [] } 6102 + $0.repositoryPersistence.saveRoots = { _ in } 6103 + $0.gitClient.isGitRepository = { _ in false } 6104 + $0.gitClient.worktrees = { _ in [] } 6105 + $0.analyticsClient.capture = { _, _ in } 6106 + $0.uuid = .incrementing 6107 + } 6108 + store.exhaustivity = .off(showSkippedAssertions: false) 6109 + 6110 + let targets = [ 6111 + RepositoriesFeature.DeleteWorktreeTarget( 6112 + worktreeID: worktreeA.id, repositoryID: folderA.id), 6113 + RepositoriesFeature.DeleteWorktreeTarget( 6114 + worktreeID: worktreeB.id, repositoryID: folderB.id), 6115 + ] 6116 + 6117 + await store.send(.requestDeleteSidebarItems(targets)) { 6118 + #expect($0.alert != nil) 6119 + } 6120 + 6121 + await store.send( 6122 + .alert(.presented(.confirmDeleteSidebarItems(targets, disposition: .folderUnlink))) 6123 + ) 6124 + // `.confirmDeleteSidebarItems` fans into the per-target 6125 + // `.confirmDeleteSidebarItem(target, action:)` which maps the 6126 + // folder intent before sending `.deleteSidebarItemConfirmed`. 6127 + await store.skipReceivedActions() 6128 + 6129 + #expect(store.state.repositories.isEmpty) 6130 + } 6131 + 6132 + @Test func requestDeleteSidebarItemsRejectsMixedKindSelection() async { 6133 + // Safety net: if a keyboard shortcut or programmatic path 6134 + // forwards a mixed folder + git selection to 6135 + // `.requestDeleteSidebarItems`, the reducer refuses rather than 6136 + // showing an ambiguous alert. The UI context menu blocks mixed 6137 + // bulk upstream so this only fires under hotkey edge cases. 6138 + let gitRoot = "/tmp/\(UUID().uuidString)-git" 6139 + let gitURL = URL(fileURLWithPath: gitRoot) 6140 + let gitMain = Worktree( 6141 + id: "\(gitRoot)/main", 6142 + name: "main", 6143 + detail: "", 6144 + workingDirectory: gitURL, 6145 + repositoryRootURL: gitURL 6146 + ) 6147 + let gitFeature = Worktree( 6148 + id: "\(gitRoot)/feature", 6149 + name: "feature", 6150 + detail: "", 6151 + workingDirectory: gitURL.appending(path: "feature"), 6152 + repositoryRootURL: gitURL 6153 + ) 6154 + let gitRepo = Repository( 6155 + id: gitRoot, 6156 + rootURL: gitURL, 6157 + name: "git-repo", 6158 + worktrees: IdentifiedArray(uniqueElements: [gitMain, gitFeature]), 6159 + isGitRepository: true 6160 + ) 6161 + let folderRoot = "/tmp/\(UUID().uuidString)-folder" 6162 + let folderURL = URL(fileURLWithPath: folderRoot) 6163 + let folderMain = Worktree( 6164 + id: Repository.folderWorktreeID(for: folderURL), 6165 + name: Repository.name(for: folderURL), 6166 + detail: "", 6167 + workingDirectory: folderURL, 6168 + repositoryRootURL: folderURL 6169 + ) 6170 + let folderRepo = Repository( 6171 + id: folderRoot, 6172 + rootURL: folderURL, 6173 + name: Repository.name(for: folderURL), 6174 + worktrees: IdentifiedArray(uniqueElements: [folderMain]), 6175 + isGitRepository: false 6176 + ) 6177 + 6178 + var state = RepositoriesFeature.State() 6179 + state.repositories = [gitRepo, folderRepo] 6180 + state.repositoryRoots = [gitURL, folderURL] 6181 + state.isInitialLoadComplete = true 6182 + 6183 + let store = TestStore(initialState: state) { 6184 + RepositoriesFeature() 6185 + } 6186 + store.exhaustivity = .off(showSkippedAssertions: false) 6187 + 6188 + await store.send( 6189 + .requestDeleteSidebarItems([ 6190 + RepositoriesFeature.DeleteWorktreeTarget( 6191 + worktreeID: gitFeature.id, repositoryID: gitRepo.id), 6192 + RepositoriesFeature.DeleteWorktreeTarget( 6193 + worktreeID: folderMain.id, repositoryID: folderRepo.id), 6194 + ])) 6195 + #expect(store.state.alert == nil) 6196 + } 6197 + 6198 + @Test func deleteScriptCompletedDoesNotMisrouteWhenGitRepoIsRemovingConcurrently() async { 6199 + // Regression: when a git repo's worktree has a delete script 6200 + // in flight AND the user confirmed repo-level removal on the 6201 + // same git repo, `removingRepositoryIDs` carries a `.git` 6202 + // intent. `.deleteScriptCompleted` must still route to the git 6203 + // `.deleteWorktreeApply` path (so `gitClient.removeWorktree` 6204 + // deletes the worktree on disk) and not mistake the entry for 6205 + // folder intent. 6206 + let repoRoot = "/tmp/\(UUID().uuidString)-git" 6207 + let repoURL = URL(fileURLWithPath: repoRoot) 6208 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 6209 + let featureWorktree = makeWorktree( 6210 + id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) 6211 + let gitRepo = Repository( 6212 + id: repoRoot, 6213 + rootURL: repoURL, 6214 + name: URL(fileURLWithPath: repoRoot).lastPathComponent, 6215 + worktrees: IdentifiedArray(uniqueElements: [mainWorktree, featureWorktree]), 6216 + isGitRepository: true 6217 + ) 6218 + 6219 + var state = RepositoriesFeature.State() 6220 + state.repositories = [gitRepo] 6221 + state.repositoryRoots = [repoURL] 6222 + state.isInitialLoadComplete = true 6223 + state.deleteScriptWorktreeIDs.insert(featureWorktree.id) 6224 + state.seedRemovalBatch(pending: [gitRepo.id: .gitRepositoryUnlink]) 6225 + 6226 + let removeCalled = LockIsolated(false) 6227 + let store = TestStore(initialState: state) { 6228 + RepositoriesFeature() 6229 + } withDependencies: { 6230 + $0.gitClient.removeWorktree = { worktree, _ in 6231 + removeCalled.setValue(true) 6232 + return await MainActor.run { worktree.workingDirectory } 6233 + } 6234 + $0.analyticsClient.capture = { _, _ in } 6235 + } 6236 + store.exhaustivity = .off(showSkippedAssertions: false) 6237 + 6238 + await store.send( 6239 + .deleteScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0, tabId: nil) 6240 + ) 6241 + await store.receive(\.deleteWorktreeApply) 6242 + await store.skipReceivedActions() 6243 + 6244 + #expect(removeCalled.value == true) 6245 + } 6246 + 6247 + @Test func deleteSidebarItemConfirmedIsIdempotentForFolderWithEmptyScript() async { 6248 + // Regression for the double-tap bug: the empty-script folder 6249 + // branch of `.deleteSidebarItemConfirmed` used to re-fire the 6250 + // repo-removal terminal (and duplicate analytics) on every repeat 6251 + // of the confirm action because it had no re-entrancy guard. 6252 + // The first invocation sets `removingRepositoryIDs` and drains 6253 + // through the batch aggregator; the second must now be a no-op. 6254 + let folderRoot = "/tmp/\(UUID().uuidString)-folder" 6255 + let folderURL = URL(fileURLWithPath: folderRoot) 6256 + let folderWorktree = Worktree( 6257 + id: Repository.folderWorktreeID(for: folderURL), 6258 + name: Repository.name(for: folderURL), 6259 + detail: "", 6260 + workingDirectory: folderURL, 6261 + repositoryRootURL: folderURL 6262 + ) 6263 + let folderRepo = Repository( 6264 + id: folderRoot, 6265 + rootURL: folderURL, 6266 + name: Repository.name(for: folderURL), 6267 + worktrees: IdentifiedArray(uniqueElements: [folderWorktree]), 6268 + isGitRepository: false 6269 + ) 6270 + 6271 + var state = RepositoriesFeature.State() 6272 + state.repositories = [folderRepo] 6273 + state.repositoryRoots = [folderURL] 6274 + state.isInitialLoadComplete = true 6275 + // Already-set: matches the state after the first 6276 + // `.deleteSidebarItemConfirmed` has enqueued 6277 + // `.repositoryRemovalCompleted`. 6278 + state.seedRemovalBatch(pending: [folderRepo.id: .folderUnlink]) 6279 + state.deletingWorktreeIDs.insert(folderWorktree.id) 6280 + 6281 + let store = TestStore(initialState: state) { 6282 + RepositoriesFeature() 6283 + } 6284 + 6285 + // Second rapid tap: reducer must short-circuit before the 6286 + // empty-script branch to avoid firing the repo-removal terminal 6287 + // again. 6288 + await store.send(.deleteSidebarItemConfirmed(folderWorktree.id, folderRepo.id)) 6289 + } 6290 + 6291 + @Test func concurrentFolderAndSectionBatchesEachCompleteIndependently() async { 6292 + // Regression: the old single-optional `activeRemovalBatch` would 6293 + // clobber a mid-flight folder batch as soon as a git-section 6294 + // remove confirmed, orphaning the folder completions into a 6295 + // fan-out of solo terminals. Keying batches by id means a folder 6296 + // trash in-flight and a section unlink can coexist; each batch 6297 + // fires its own `.repositoriesRemoved` when its pending set 6298 + // drains. 6299 + let folderRoot = "/tmp/\(UUID().uuidString)-folder" 6300 + let folderURL = URL(fileURLWithPath: folderRoot) 6301 + let folderWorktree = Worktree( 6302 + id: Repository.folderWorktreeID(for: folderURL), 6303 + name: Repository.name(for: folderURL), 6304 + detail: "", 6305 + workingDirectory: folderURL, 6306 + repositoryRootURL: folderURL 6307 + ) 6308 + let folderRepo = Repository( 6309 + id: folderRoot, rootURL: folderURL, name: Repository.name(for: folderURL), 6310 + worktrees: IdentifiedArray(uniqueElements: [folderWorktree]), 6311 + isGitRepository: false 6312 + ) 6313 + let gitRoot = "/tmp/\(UUID().uuidString)-repo" 6314 + let gitURL = URL(fileURLWithPath: gitRoot) 6315 + let gitMain = Worktree( 6316 + id: gitRoot, name: Repository.name(for: gitURL), detail: "", 6317 + workingDirectory: gitURL, repositoryRootURL: gitURL 6318 + ) 6319 + let gitRepo = Repository( 6320 + id: gitRoot, rootURL: gitURL, name: Repository.name(for: gitURL), 6321 + worktrees: IdentifiedArray(uniqueElements: [gitMain]), 6322 + isGitRepository: true 6323 + ) 6324 + 6325 + // Seed state with a folder batch already mid-flight — mimics the 6326 + // window where the folder's delete script / trash is still 6327 + // running after the user confirmed. 6328 + var state = RepositoriesFeature.State() 6329 + state.repositories = [folderRepo, gitRepo] 6330 + state.repositoryRoots = [folderURL, gitURL] 6331 + state.isInitialLoadComplete = true 6332 + let folderBatchID = state.seedRemovalBatch(pending: [folderRepo.id: .folderUnlink]) 6333 + 6334 + let store = TestStore(initialState: state) { 6335 + RepositoriesFeature() 6336 + } withDependencies: { 6337 + $0.repositoryPersistence.loadRoots = { [] } 6338 + $0.repositoryPersistence.saveRoots = { _ in } 6339 + $0.gitClient.isGitRepository = { _ in true } 6340 + $0.gitClient.worktrees = { _ in [] } 6341 + $0.analyticsClient.capture = { _, _ in } 6342 + $0.uuid = .incrementing 6343 + } 6344 + store.exhaustivity = .off(showSkippedAssertions: false) 6345 + 6346 + // User confirms the git-section remove while the folder batch is 6347 + // still pending. The section-remove must mint its own batch id 6348 + // and leave the folder batch untouched. 6349 + await store.send(.alert(.presented(.confirmDeleteRepository(gitRepo.id)))) 6350 + #expect(store.state.activeRemovalBatches[folderBatchID] != nil) 6351 + #expect(store.state.activeRemovalBatches.count == 2) 6352 + 6353 + // Folder completion arrives: drains its own batch, fires its own 6354 + // terminal, leaves the git batch alone. 6355 + await store.send( 6356 + .repositoryRemovalCompleted(folderRepo.id, outcome: .success, selectionWasRemoved: false)) 6357 + await store.skipReceivedActions() 6358 + #expect(store.state.activeRemovalBatches[folderBatchID] == nil) 6359 + #expect(store.state.repositories.contains(where: { $0.id == gitRepo.id }) == false) 6360 + #expect(!store.state.repositories.contains(where: { $0.id == folderRepo.id })) 6361 + #expect(store.state.removingRepositoryIDs.isEmpty) 6362 + #expect(store.state.activeRemovalBatches.isEmpty) 6363 + } 6364 + 6365 + @Test func orphanCompletionReportsIssueAndFiresSoloTerminal() async { 6366 + // Every sender seeds the batch before signalling, so an orphan 6367 + // completion means a bug. `reportIssue` fails tests and warns 6368 + // release. For `succeeded=true` the solo terminal still runs so 6369 + // the repo eventually leaves state; for `succeeded=false` any 6370 + // worktree-scoped trackers get defensively cleared so state 6371 + // can't leak beyond the failed attempt. 6372 + await withKnownIssue { 6373 + let folderRoot = "/tmp/\(UUID().uuidString)-folder" 6374 + let folderURL = URL(fileURLWithPath: folderRoot) 6375 + let folderWorktree = Worktree( 6376 + id: Repository.folderWorktreeID(for: folderURL), 6377 + name: Repository.name(for: folderURL), detail: "", 6378 + workingDirectory: folderURL, repositoryRootURL: folderURL 6379 + ) 6380 + let folderRepo = Repository( 6381 + id: folderRoot, rootURL: folderURL, name: Repository.name(for: folderURL), 6382 + worktrees: IdentifiedArray(uniqueElements: [folderWorktree]), 6383 + isGitRepository: false 6384 + ) 6385 + 6386 + var state = RepositoriesFeature.State() 6387 + state.repositories = [folderRepo] 6388 + state.repositoryRoots = [folderURL] 6389 + state.isInitialLoadComplete = true 6390 + // Record without a matching batch in `activeRemovalBatches` 6391 + // reproduces the orphan-completion scenario. 6392 + state.removingRepositoryIDs[folderRepo.id] = RepositoriesFeature.RepositoryRemovalRecord( 6393 + disposition: .folderUnlink, batchID: UUID() 6394 + ) 6395 + state.deletingWorktreeIDs.insert(folderWorktree.id) 6396 + state.deleteScriptWorktreeIDs.insert(folderWorktree.id) 6397 + 6398 + let store = TestStore(initialState: state) { 6399 + RepositoriesFeature() 6400 + } withDependencies: { 6401 + $0.repositoryPersistence.loadRoots = { [] } 6402 + $0.repositoryPersistence.saveRoots = { _ in } 6403 + $0.gitClient.isGitRepository = { _ in false } 6404 + $0.gitClient.worktrees = { _ in [] } 6405 + $0.analyticsClient.capture = { _, _ in } 6406 + } 6407 + store.exhaustivity = .off(showSkippedAssertions: false) 6408 + 6409 + await store.send( 6410 + .repositoryRemovalCompleted( 6411 + folderRepo.id, outcome: .failureSilent, selectionWasRemoved: false)) 6412 + await store.skipReceivedActions() 6413 + #expect(store.state.removingRepositoryIDs[folderRepo.id] == nil) 6414 + #expect(!store.state.deletingWorktreeIDs.contains(folderWorktree.id)) 6415 + #expect(!store.state.deleteScriptWorktreeIDs.contains(folderWorktree.id)) 6416 + #expect(store.state.repositories.contains(where: { $0.id == folderRepo.id })) 6417 + } 6418 + } 6419 + 6420 + @Test func orphanCompletionSucceededFiresSoloTerminalAndRemovesRepo() async { 6421 + // S4 companion: the `succeeded: true` branch of the orphan 6422 + // fallback should still fire a solo `.repositoriesRemoved` so 6423 + // the repo leaves state, even though the invariant is 6424 + // technically broken. `reportIssue` surfaces the bug; the 6425 + // reducer still cleans up. 6426 + await withKnownIssue { 6427 + let folderRoot = "/tmp/\(UUID().uuidString)-folder" 6428 + let folderURL = URL(fileURLWithPath: folderRoot) 6429 + let folderWorktree = Worktree( 6430 + id: Repository.folderWorktreeID(for: folderURL), 6431 + name: Repository.name(for: folderURL), detail: "", 6432 + workingDirectory: folderURL, repositoryRootURL: folderURL 6433 + ) 6434 + let folderRepo = Repository( 6435 + id: folderRoot, rootURL: folderURL, name: Repository.name(for: folderURL), 6436 + worktrees: IdentifiedArray(uniqueElements: [folderWorktree]), 6437 + isGitRepository: false 6438 + ) 6439 + 6440 + var state = RepositoriesFeature.State() 6441 + state.repositories = [folderRepo] 6442 + state.repositoryRoots = [folderURL] 6443 + state.isInitialLoadComplete = true 6444 + state.removingRepositoryIDs[folderRepo.id] = RepositoriesFeature.RepositoryRemovalRecord( 6445 + disposition: .folderUnlink, batchID: UUID() 6446 + ) 6447 + 6448 + let store = TestStore(initialState: state) { 6449 + RepositoriesFeature() 6450 + } withDependencies: { 6451 + $0.repositoryPersistence.loadRoots = { [] } 6452 + $0.repositoryPersistence.saveRoots = { _ in } 6453 + $0.repositoryPersistence.pruneRepositoryConfigs = { _ in } 6454 + $0.gitClient.isGitRepository = { _ in false } 6455 + $0.gitClient.worktrees = { _ in [] } 6456 + $0.analyticsClient.capture = { _, _ in } 6457 + } 6458 + store.exhaustivity = .off(showSkippedAssertions: false) 6459 + 6460 + await store.send( 6461 + .repositoryRemovalCompleted(folderRepo.id, outcome: .success, selectionWasRemoved: false)) 6462 + await store.skipReceivedActions() 6463 + #expect(store.state.removingRepositoryIDs[folderRepo.id] == nil) 6464 + #expect(!store.state.repositories.contains(where: { $0.id == folderRepo.id })) 6465 + } 4772 6466 } 4773 6467 4774 6468 private actor AsyncGate {
+25
supacodeTests/SettingsFeatureTests.swift
··· 160 160 } 161 161 } 162 162 163 + @Test(.dependencies) func repositoriesChangedFlipsIsGitRepositoryWithoutRebuildingState() async { 164 + let summary = SettingsRepositorySummary(id: "/tmp/repo", name: "Repo", isGitRepository: true) 165 + let scriptsSelection = SettingsSection.repositoryScripts(summary.id) 166 + var initial = SettingsFeature.State() 167 + initial.selection = scriptsSelection 168 + initial.repositorySummaries = [summary] 169 + initial.repositorySettings = RepositorySettingsFeature.State( 170 + rootURL: summary.rootURL, 171 + isGitRepository: true, 172 + settings: .default 173 + ) 174 + let store = TestStore(initialState: initial) { 175 + SettingsFeature() 176 + } 177 + 178 + // Same path, kind flipped git → folder: the feature state must 179 + // mutate in place so the scripts page picks the folder render 180 + // path without discarding the loaded `RepositorySettings`. 181 + let flipped = SettingsRepositorySummary(id: summary.id, name: summary.name, isGitRepository: false) 182 + await store.send(.repositoriesChanged([flipped])) { 183 + $0.repositorySummaries = [flipped] 184 + $0.repositorySettings?.isGitRepository = false 185 + } 186 + } 187 + 163 188 @Test(.dependencies) func setSelectionNilClosesSettingsWindow() async { 164 189 var state = SettingsFeature.State() 165 190 state.selection = .general