diff --git a/docs-ai/072-workspace-editing/000-plan.md b/docs-ai/072-workspace-editing/000-plan.md new file mode 100644 index 00000000..786e3747 --- /dev/null +++ b/docs-ai/072-workspace-editing/000-plan.md @@ -0,0 +1,157 @@ +# 072 — Workspace Editing: Plan + +| | | +| --- | --- | +| **Status** | Planned | +| **Anchor date** | 2026-09-25 | +| **Primary PRs** | (fill in as they merge) | +| **Related** | [042-project-workspaces](../042-project-workspaces/000-plan.md), [062-workspace-child-diff](../062-workspace-child-diff/000-plan.md), `docs/components/workspaces.md`, PR #602 (superseded reference) | + +## Background + +Entry 042 shipped workspaces as a create-once artifact. The creation sheet +(`WorkspaceCreationPromptFeature` / `WorkspaceCreationPromptView`) builds the folder, +materializes the member repositories and writes `.prowl/workspace.json`, but after that +nothing in the app can change the workspace: the detail view and the Settings page render +the metadata read-only and tell the user to "edit that file". A workspace therefore cannot +be renamed, described, linked to a task, extended with another repository, or trimmed +without hand-editing JSON and materializing folders manually. + +PR #602 (MikotoZero, draft, conflicting since 2026-07) proposed the right direction: +editable metadata, adding and removing child repositories after creation, per-repository +roles. It bundled that with a much larger Script Profiles / bootstrap subsystem and a +duplicated editor inside Settings, and it is far behind `main`. This entry takes the +workspace-lifecycle half of that proposal and rebuilds it on the current code base, with +one editor surface instead of two. Bootstrap scripts stay out of scope. + +## Goals + +- **One editor, two modes.** The creation sheet becomes `WorkspaceEditorFeature` with + `mode == .create | .edit`. Edit mode opens on an existing workspace, pre-filled from + `.prowl/workspace.json`, and saves through a single domain operation. +- **Editable metadata:** title, description, task links, and per-repository name and role. + Title is display-only; the workspace folder never moves. +- **Add repositories after creation** with the same sources and checkout semantics as + creation (opened repository, local folder, remote URL; Link / Create Branch / Use + Existing). New members are materialized on Save. +- **Remove repositories after creation** with the same safety model as workspace removal: + the default only drops the metadata entry; an opt-in deletes the materialized folder + (symlink for Link, `git worktree remove --force` for worktrees, folder delete for + clones), with a further opt-in to delete the recorded branch through the guarded + branch-deletion path. +- **Reorder** members (move up / down) so the sidebar and metadata list follow the user's + mental order. +- **Discoverable entry points:** sidebar workspace header menu and child row context menu, + a button on the workspace detail view, a button in Settings → the workspace's page, + the Command Palette, and the File menu next to "New Workspace…". +- **Creation parity:** the create sheet gains the same optional Description, Task Links + and per-repository Role fields so a workspace can be fully described up front. +- Keep `.prowl/workspace.json` the single source of truth; unknown keys written by other + tools or a future schema are preserved on save. + +### Non-goals + +- Bootstrap / Script Profiles (the other half of #602). If revisited, it is a new entry. +- Changing an existing member's source, path, or checkout mode. Those require remove + + re-add; the editor shows them as read-only provenance. +- Renaming the workspace folder on disk when the title changes. +- Editing from the `prowl` CLI. + +## Design / Approach + +### Domain (`supacode/Domain/ProjectWorkspace.swift`) + +- `ProjectWorkspaceCreationDraft` gains `description`, `taskLinks`; `ProjectWorkspaceRepositoryPlan` + and `ProjectWorkspaceCreationRepository` gain `role`. `create` writes them. +- New `ProjectWorkspaceUpdateRequest` = root URL + edited metadata + ordered member list, + where each member is either an existing `RepositoryEntry` (name/role possibly edited) + or a new `ProjectWorkspaceRepositoryPlan`, plus a list of `ProjectWorkspaceRepositoryRemoval` + (entry, `deleteFiles`, `deleteBranch`). +- New `ProjectWorkspace.update(_:fileManager:gitRunner:) async throws -> ProjectWorkspaceUpdateResult`. + Order of operations is chosen so nothing already on disk is lost when the risky step + fails: + 1. validate (title, ≥1 member after the edit, new-member checkouts); + 2. materialize new members through the existing `materialize` + `MaterializationLedger` + (occupied names seeded with every current entry path, including entries being + removed, so a re-added repository never collides with a folder that still exists); + 3. write the new metadata atomically, patching the existing JSON so unknown top-level + and per-entry keys survive, with `updated_at` refreshed and `created_at` kept; + 4. clean up removed members best-effort (symlink → unlink; worktree → `git worktree + remove --force` against the recorded source; clone → delete folder; then optional + branch deletion is returned to the caller for the guarded `GitClient` path, like + workspace removal does today). + Failure in 1–3 rolls back the ledger and leaves the old metadata untouched. Failures in + step 4 are collected and reported (`ProjectWorkspaceUpdateResult.cleanupFailures`), the + metadata is already committed. +- Minimum member count drops from two to one. With editing available a workspace is a + growable container, and forcing two repositories up front only blocks the natural + "start with the app, add the API later" flow. Removal in the editor refuses to drop the + last member. + +### Reducer (`supacode/Features/Repositories/Reducer/`) + +- `WorkspaceCreationPromptFeature.swift` → `WorkspaceEditorFeature.swift`. `State` gains + `mode`, `description`, `taskLinks`, `existingRepositories` + (`IdentifiedArrayOf`: entry, editable name/role, + `pendingRemoval: RemovalChoice?`), and reordering. `repositories` keeps holding the + new rows exactly as today, so the remote prompt, base-ref loading and per-row validation + are reused verbatim. `Delegate.submit` carries an enum: `.create(draft)` or + `.update(request)`. +- `RepositoriesFeature+WorkspaceCreation.swift` keeps the creation flow; a new + `RepositoriesFeature+WorkspaceEditing.swift` handles `editPromptRequested(id, removingChild:)` + (re-reads the metadata from disk in an effect, falls back to the loaded snapshot), + `saveWorkspace(request)`, `workspaceSaved`, `workspaceSaveFailed`. On success: + reload repositories, toast, and an alert listing cleanup failures if any. The presented + state is renamed `workspaceEditor`. +- Entry points: `RepositorySectionView` header menu + `WorkspaceChildRowsView` context + menu ("Edit Workspace…", "Remove from Workspace…" pre-marks that child), `WorkspaceDetailView` + header button, `RepositorySettingsView` workspace section button → `RepositorySettingsFeature.Delegate.editWorkspace` + → `SettingsFeature` → `AppFeature` surfaces the main window and sends the request, + `CommandPaletteItem.editWorkspace` (gated on the selected repository being a workspace), + `WorktreeCommands` "Edit Workspace…" (same gate). + +### Views (`supacode/Features/Repositories/Views/`) + +- `WorkspaceCreationPromptView.swift` → `WorkspaceEditorView.swift`. Sections: header + (mode-specific title, root path in edit mode), Title, Folder (create only), Description, + Task Links (list with add/remove), Repositories (existing rows first: name, role, source + badge, checkout/branch and path read-only, Move Up/Down, Remove with inline cleanup + options and Undo; then new rows as today plus Role), footer with Cancel / Create|Save. +- `WorkspaceDetailView` shows roles, opens task links that parse as URLs, and gets an + "Edit Workspace…" button. `WorkspaceRepositoriesGridView` unchanged apart from copy. +- Settings workspace section: keep the read-only grid, replace the "edit that file" footer + with an "Edit Workspace…" button and a hint that the file remains the source of truth. + +### Docs & tests + +- `docs/components/workspaces.md`: new "Editing a workspace" section; metadata section no + longer says read-only; minimum count updated. `docs/components/sidebar.md` / + `command-palette.md` only if they enumerate the affected menus. +- Tests: domain (`ProjectWorkspaceTests`): update writes/patches metadata, materializes + new members and rolls back on failure without touching existing ones, cleanup by + kind, keep-files path, occupied-name seeding. Editor reducer: edit-mode population, + field edits, removal mark/unmark and last-member guard, reorder, submit payloads for + both modes. `RepositoriesFeature`: edit prompt request, save success/failure, + settings-originated request through `AppFeature`. + +## Alternatives & decisions + +- **Second editor inside Settings (as #602 did)** — rejected. Two surfaces for the same + mutations drift; Settings gets a button that opens the one editor on the main window. +- **Immediate per-action writes (Add Repository saves at once, as #602 did)** — rejected in + favor of a transactional Save. Users can stage several changes, cancel cleanly, and the + rollback story stays the same as creation. +- **Removals before additions** — rejected. Removal is irreversible; if the subsequent + clone or worktree add failed, the user would have lost a member while the metadata still + listed it. Additions run first and can be rolled back; removals run last after the + metadata is committed. +- **Default to deleting a removed member's folder** — rejected; matches workspace removal + where "remove from Prowl" never touches disk unless opted in. `git worktree remove + --force` discards uncommitted work, so it must be explicit. +- **Keep the two-repository minimum** — rejected once editing exists; see Domain. +- **Whole-file rewrite that drops unknown JSON keys** — rejected; a JSON patch that keeps + unknown top-level and per-entry keys costs little and protects forward compatibility. + +## Amendments + +(none yet) diff --git a/docs-ai/README.md b/docs-ai/README.md index fcc5aeff..e7e62df7 100644 --- a/docs-ai/README.md +++ b/docs-ai/README.md @@ -131,3 +131,4 @@ agent-facing manual for that). | 069 | [undo-close-terminal](069-undo-close-terminal/000-plan.md) | 2026-09-16 | Undo a pane or tab close with ⌘Z within Ghostty’s `undo-timeout`: closed surfaces stay alive off-tree and restore into their original tab and split position | | 070 | [app-localization](070-app-localization/000-plan.md) | 2026-09-18 | Simplified Chinese UI from a String Catalog; translations sync at release time through the `sync-l10n` skill (nothing blocks everyday work); compiler-based coverage audit plus a baseline for unlocalized copy; living [glossary](070-app-localization/glossary.md); language choice stored only in the per-app `AppleLanguages` default | | 071 | [git-environment-recovery](071-git-environment-recovery/000-plan.md) | 2026-09-20 | Working Git selection, safe repository classification, recovery guidance, and plain-folder Shelf entry | +| 072 | [workspace-editing](072-workspace-editing/000-plan.md) | 2026-09-25 | Edit a workspace after creation: metadata, member repositories (add, remove, reorder, roles) through one editor sheet reached from the sidebar, detail view, Settings, palette, and menu | -- 2.51.2 From e6678a3de1a37243867f27b60a77d8c994ca5670 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 25 Sep 2026 17:06:38 +0800 Subject: [PATCH 02/15] Add ProjectWorkspace.update with description, task links, and roles Workspaces can now be edited after creation at the domain level: update materializes added members first, rewrites .prowl/workspace.json while preserving unknown keys, then cleans up removed members best-effort and reports what was left on disk. Creation accepts a single repository and writes description, task links, and per-repository roles. --- supacode/Domain/ProjectWorkspace.swift | 321 ++++++++++- ...ure.swift => WorkspaceEditorFeature.swift} | 0 ...ptView.swift => WorkspaceEditorView.swift} | 0 .../ProjectWorkspaceUpdateTests.swift | 519 ++++++++++++++++++ 4 files changed, 834 insertions(+), 6 deletions(-) rename supacode/Features/Repositories/Reducer/{WorkspaceCreationPromptFeature.swift => WorkspaceEditorFeature.swift} (100%) rename supacode/Features/Repositories/Views/{WorkspaceCreationPromptView.swift => WorkspaceEditorView.swift} (100%) create mode 100644 supacodeTests/ProjectWorkspaceUpdateTests.swift diff --git a/supacode/Domain/ProjectWorkspace.swift b/supacode/Domain/ProjectWorkspace.swift index 2cdb552d..3cf7400f 100644 --- a/supacode/Domain/ProjectWorkspace.swift +++ b/supacode/Domain/ProjectWorkspace.swift @@ -60,6 +60,7 @@ nonisolated enum ProjectWorkspaceRepositoryCheckout: Equatable, Hashable, Sendab nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Sendable, Identifiable { var id: String var name: String + var role: String? var path: String? var sourceKind: ProjectWorkspaceRepositorySourceKind var sourceLocation: String @@ -201,11 +202,30 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send nonisolated struct ProjectWorkspaceRepositoryPlan: Equatable, Sendable, Identifiable { var id: String var name: String + var role: String? var path: String? var sourceKind: ProjectWorkspaceRepositorySourceKind var sourceLocation: String var checkout: ProjectWorkspaceRepositoryCheckout + init( + id: String, + name: String, + role: String? = nil, + path: String?, + sourceKind: ProjectWorkspaceRepositorySourceKind, + sourceLocation: String, + checkout: ProjectWorkspaceRepositoryCheckout + ) { + self.id = id + self.name = name + self.role = role + self.path = path + self.sourceKind = sourceKind + self.sourceLocation = sourceLocation + self.checkout = checkout + } + var localSourceURL: URL? { sourceKind.localSourceURL(from: sourceLocation) } @@ -213,15 +233,21 @@ nonisolated struct ProjectWorkspaceRepositoryPlan: Equatable, Sendable, Identifi nonisolated struct ProjectWorkspaceCreationDraft: Equatable, Sendable { var title: String + var description: String + var taskLinks: [String] var rootURL: URL var repositories: [ProjectWorkspaceRepositoryPlan] init( title: String, + description: String = "", + taskLinks: [String] = [], rootURL: URL, repositories: [ProjectWorkspaceRepositoryPlan] ) { self.title = title + self.description = description + self.taskLinks = taskLinks self.rootURL = rootURL.standardizedFileURL self.repositories = repositories } @@ -232,6 +258,80 @@ nonisolated struct ProjectWorkspaceCreationRequest: Equatable, Sendable { var createdAt: Date } +/// One member of an edited workspace, in the order it should appear in the +/// saved metadata. Existing entries carry the (possibly renamed / re-roled) +/// metadata as-is; added members are materialized on save. +nonisolated enum ProjectWorkspaceUpdateMember: Equatable, Sendable { + case existing(ProjectWorkspaceRepositoryEntry) + case added(ProjectWorkspaceRepositoryPlan) +} + +/// A member dropped from the workspace. `deleteFiles` removes what Prowl +/// materialized under the workspace root (symlink, worktree, or clone); +/// `deleteBranch` additionally asks the caller to delete the recorded branch +/// through the guarded branch-deletion path once the worktree is gone. +nonisolated struct ProjectWorkspaceRepositoryRemoval: Equatable, Sendable, Identifiable { + var entry: ProjectWorkspaceRepositoryEntry + var deleteFiles: Bool + var deleteBranch: Bool + + var id: String { entry.id } + + init(entry: ProjectWorkspaceRepositoryEntry, deleteFiles: Bool = false, deleteBranch: Bool = false) { + self.entry = entry + self.deleteFiles = deleteFiles + self.deleteBranch = deleteBranch + } +} + +nonisolated struct ProjectWorkspaceUpdateRequest: Equatable, Sendable { + var rootURL: URL + var title: String + var description: String + var taskLinks: [String] + var members: [ProjectWorkspaceUpdateMember] + var removals: [ProjectWorkspaceRepositoryRemoval] + var updatedAt: Date + + init( + rootURL: URL, + title: String, + description: String = "", + taskLinks: [String] = [], + members: [ProjectWorkspaceUpdateMember], + removals: [ProjectWorkspaceRepositoryRemoval] = [], + updatedAt: Date + ) { + self.rootURL = rootURL.standardizedFileURL + self.title = title + self.description = description + self.taskLinks = taskLinks + self.members = members + self.removals = removals + self.updatedAt = updatedAt + } +} + +/// A removed member whose files stayed on disk. The metadata is already +/// saved without the entry, so this is reported rather than thrown. +nonisolated struct ProjectWorkspaceCleanupFailure: Equatable, Sendable, Identifiable { + var entryID: String + var entryName: String + var message: String + + var id: String { entryID } +} + +nonisolated struct ProjectWorkspaceUpdateResult: Equatable, Sendable { + var workspace: ProjectWorkspace + var cleanupFailures: [ProjectWorkspaceCleanupFailure] + + /// Removals whose files were cleaned up, so branch deletion can proceed + /// safely. A worktree whose unregistration failed is excluded: deleting + /// its branch would leave git with a dangling checkout. + var completedRemovals: [ProjectWorkspaceRepositoryRemoval] +} + nonisolated struct ProjectWorkspaceGitCommand: Equatable, Sendable { var arguments: [String] var currentDirectoryURL: URL? @@ -256,6 +356,7 @@ nonisolated enum ProjectWorkspaceCreationError: LocalizedError, Equatable, Senda case linkCheckoutUnsupported(String) case destinationIsFile(String) case workspaceAlreadyExists(String) + case workspaceMetadataMissing(String) case repositoryDoesNotExist(String) case linkAlreadyExists(String) case gitCommandFailed(command: String, message: String) @@ -267,7 +368,7 @@ nonisolated enum ProjectWorkspaceCreationError: LocalizedError, Equatable, Senda case .missingPath: return String(localized: "Workspace folder required.") case .notEnoughRepositories: - return String(localized: "Select at least two repositories.") + return String(localized: "Add at least one repository.") case .missingRepositoryName: return String(localized: "Repository name required.") case .missingRepositorySource(let name): @@ -282,6 +383,8 @@ nonisolated enum ProjectWorkspaceCreationError: LocalizedError, Equatable, Senda return String(localized: "\(path) is a file. Choose a folder path instead.") case .workspaceAlreadyExists(let path): return String(localized: "\(path) already contains a Prowl workspace.") + case .workspaceMetadataMissing(let path): + return String(localized: "\(path) has no workspace metadata to update.") case .repositoryDoesNotExist(let path): return String(localized: "\(path) does not exist.") case .linkAlreadyExists(let path): @@ -522,7 +625,7 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { guard !title.isEmpty else { throw ProjectWorkspaceCreationError.missingTitle } - guard request.draft.repositories.count >= 2 else { + guard !request.draft.repositories.isEmpty else { throw ProjectWorkspaceCreationError.notEnoughRepositories } let rootPath = normalizedPath(request.draft.rootURL, resolvingSymlinks: false) @@ -570,15 +673,15 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { let workspace = ProjectWorkspace( id: rootPath, title: title, + description: request.draft.description, + taskLinks: request.draft.taskLinks, repositories: entries, createdAt: request.createdAt, updatedAt: request.createdAt ) .normalized(relativeTo: rootURL) - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] - try encoder.encode(workspace).write(to: metadataURL, options: .atomic) + try encodeMetadata(workspace, preservingUnknownKeysIn: nil) + .write(to: metadataURL, options: .atomic) ledger.createdURLs.append(metadataURL) return workspace } catch { @@ -587,6 +690,211 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } } + /// Applies an edit to an existing workspace. Steps run in the order that + /// keeps what is already on disk safe when the risky step fails: + /// + /// 1. validate the request; + /// 2. materialize added members (rolled back on failure); + /// 3. write the metadata atomically, keeping unknown JSON keys; + /// 4. clean up removed members best-effort, after the metadata no longer + /// references them. Cleanup failures are reported, not thrown. + static func update( + _ request: ProjectWorkspaceUpdateRequest, + fileManager: FileManager = .default, + gitRunner: ProjectWorkspaceGitRunner + ) async throws -> ProjectWorkspaceUpdateResult { + let title = request.title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { + throw ProjectWorkspaceCreationError.missingTitle + } + guard !request.members.isEmpty else { + throw ProjectWorkspaceCreationError.notEnoughRepositories + } + let rootPath = normalizedPath(request.rootURL, resolvingSymlinks: false) + let rootURL = URL(fileURLWithPath: rootPath, isDirectory: true).standardizedFileURL + let metadataURL = metadataURL(for: rootURL) + guard let existingData = try? Data(contentsOf: metadataURL), let existing = load(from: rootURL) + else { + throw ProjectWorkspaceCreationError.workspaceMetadataMissing(rootPath) + } + + var ledger = MaterializationLedger() + // Every current path stays occupied, including members being removed: + // their folders are still on disk while additions are materialized, so a + // re-added repository gets a suffixed folder instead of a collision. + ledger.occupiedNames = Set(existing.repositories.map { $0.path.lowercased() }) + let workspace: ProjectWorkspace + do { + var entries: [RepositoryEntry] = [] + for member in request.members { + switch member { + case .existing(let entry): + entries.append(entry) + case .added(let plan): + entries.append( + try await materialize( + plan, + workspaceRootURL: rootURL, + ledger: &ledger, + fileManager: fileManager, + gitRunner: gitRunner + ) + ) + } + } + var updated = existing + updated.title = title + updated.description = request.description + updated.taskLinks = request.taskLinks + updated.repositories = entries + updated.updatedAt = request.updatedAt + workspace = updated.normalized(relativeTo: rootURL) + try encodeMetadata(workspace, preservingUnknownKeysIn: existingData) + .write(to: metadataURL, options: .atomic) + } catch { + await rollback(ledger, rootURL: rootURL, fileManager: fileManager, gitRunner: gitRunner) + throw error + } + + var cleanupFailures: [ProjectWorkspaceCleanupFailure] = [] + var completedRemovals: [ProjectWorkspaceRepositoryRemoval] = [] + for removal in request.removals { + guard removal.deleteFiles else { + continue + } + if let message = await removeMemberFiles( + removal.entry, rootURL: rootURL, fileManager: fileManager, gitRunner: gitRunner) + { + cleanupFailures.append( + ProjectWorkspaceCleanupFailure( + entryID: removal.entry.id, entryName: removal.entry.name, message: message)) + } else { + completedRemovals.append(removal) + } + } + return ProjectWorkspaceUpdateResult( + workspace: workspace, + cleanupFailures: cleanupFailures, + completedRemovals: completedRemovals + ) + } + + /// Removes what Prowl materialized for one member under the workspace root. + /// Returns a user-facing message when the folder was left in place. Paths + /// outside the workspace root and folders Prowl did not create are never + /// deleted. + private static func removeMemberFiles( + _ entry: RepositoryEntry, + rootURL: URL, + fileManager: FileManager, + gitRunner: ProjectWorkspaceGitRunner + ) async -> String? { + let rootPath = normalizedPath(rootURL, resolvingSymlinks: false) + let entryURL = entry.resolvedURL(relativeTo: rootURL) + let entryPath = entryURL.path(percentEncoded: false) + guard entryPath.hasPrefix(rootPath + "/") else { + return String(localized: "\(entryPath) is outside the workspace folder and was left in place.") + } + let isSymbolicLink = + (try? entryURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true + guard isSymbolicLink || fileManager.fileExists(atPath: entryPath) else { + return nil + } + do { + if isSymbolicLink || entry.sourceKind == .remote { + // A symlink removal never touches the linked checkout; a clone is + // wholly owned by the workspace. + try fileManager.removeItem(at: entryURL) + return nil + } + guard let sourceLocation = entry.sourceLocation else { + return String(localized: "\(entryPath) was not created by Prowl and was left in place.") + } + try await gitRunner.run( + ProjectWorkspaceGitCommand( + arguments: ["-C", sourceLocation, "worktree", "remove", "--force", entryPath], + currentDirectoryURL: nil + ) + ) + return nil + } catch { + log.warning("Workspace member cleanup failed for \(entryPath): \(error)") + return error.localizedDescription + } + } + + private static let knownTopLevelKeys = Set( + [ + CodingKeys.schemaVersion, .id, .title, .description, .taskLinks, .repositories, .createdAt, + .updatedAt, + ].map(\.stringValue)) + private static let knownEntryKeys = Set( + [ + RepositoryEntry.CodingKeys.id, .name, .role, .path, .sourceKind, .sourceLocation, + .branchName, .baseRef, + ].map(\.stringValue)) + + /// Encodes the metadata. When the existing file is supplied, keys this + /// version of Prowl does not model (top-level, and per repository entry + /// matched by `id`) are carried over so other tools' fields survive a save; + /// known keys always follow the model, so a cleared optional is dropped. + static func encodeMetadata( + _ workspace: ProjectWorkspace, + preservingUnknownKeysIn existingData: Data? + ) throws -> Data { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let encoded = try encoder.encode(workspace) + guard let existingData, + let existingObject = try? JSONSerialization.jsonObject(with: existingData) as? [String: Any], + let newObject = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] + else { + return encoded + } + var merged = mergedObject(existing: existingObject, new: newObject, knownKeys: knownTopLevelKeys) + if let newEntries = newObject[CodingKeys.repositories.stringValue] as? [[String: Any]] { + let existingEntries = + existingObject[CodingKeys.repositories.stringValue] as? [[String: Any]] ?? [] + // Key by the same fallback chain `normalized` uses for a missing id, + // so hand-written entries without an `id` still keep their extra keys. + var existingEntriesByID: [String: [String: Any]] = [:] + for entry in existingEntries { + let key = + (entry[RepositoryEntry.CodingKeys.id.stringValue] as? String)?.nilIfEmpty + ?? (entry[RepositoryEntry.CodingKeys.path.stringValue] as? String)?.nilIfEmpty + ?? (entry[RepositoryEntry.CodingKeys.name.stringValue] as? String)?.nilIfEmpty + if let key { + existingEntriesByID[key] = entry + } + } + merged[CodingKeys.repositories.stringValue] = newEntries.map { entry in + guard let key = entry[RepositoryEntry.CodingKeys.id.stringValue] as? String, + let existingEntry = existingEntriesByID[key] + else { + return entry + } + return mergedObject(existing: existingEntry, new: entry, knownKeys: knownEntryKeys) + } + } + return try JSONSerialization.data( + withJSONObject: merged, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + ) + } + + private static func mergedObject( + existing: [String: Any], + new: [String: Any], + knownKeys: Set + ) -> [String: Any] { + var result = existing.filter { !knownKeys.contains($0.key) } + for (key, value) in new { + result[key] = value + } + return result + } + private struct MaterializationLedger: Sendable { var occupiedNames: Set = [] var createdURLs: [URL] = [] @@ -815,6 +1123,7 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { return RepositoryEntry( id: repository.id.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ?? workspacePath, name: name, + role: repository.role?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, path: workspacePath, sourceKind: repository.sourceKind, sourceLocation: normalizedSourceLocation, diff --git a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift b/supacode/Features/Repositories/Reducer/WorkspaceEditorFeature.swift similarity index 100% rename from supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift rename to supacode/Features/Repositories/Reducer/WorkspaceEditorFeature.swift diff --git a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift b/supacode/Features/Repositories/Views/WorkspaceEditorView.swift similarity index 100% rename from supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift rename to supacode/Features/Repositories/Views/WorkspaceEditorView.swift diff --git a/supacodeTests/ProjectWorkspaceUpdateTests.swift b/supacodeTests/ProjectWorkspaceUpdateTests.swift new file mode 100644 index 00000000..450fc31f --- /dev/null +++ b/supacodeTests/ProjectWorkspaceUpdateTests.swift @@ -0,0 +1,519 @@ +import ComposableArchitecture +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct ProjectWorkspaceUpdateTests { + @Test func createWritesDescriptionTaskLinksAndRoleAndAllowsOneRepository() async throws { + let rootURL = try makeTemporaryRoot(prefix: "prowl-create-single") + let appURL = try makeTemporaryRoot(prefix: "prowl-app") + defer { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: appURL) + } + + let workspace = try await ProjectWorkspace.create( + ProjectWorkspaceCreationRequest( + draft: ProjectWorkspaceCreationDraft( + title: "Solo", + description: " One repo to start ", + taskLinks: ["https://example.com/1"], + rootURL: rootURL, + repositories: [ + ProjectWorkspaceRepositoryPlan( + id: "app", + name: "App", + role: " macOS app ", + path: nil, + sourceKind: .existingPath, + sourceLocation: appURL.path(percentEncoded: false), + checkout: .link + ) + ] + ), + createdAt: Date(timeIntervalSince1970: 1_000) + ), + gitRunner: failingGitRunner + ) + + #expect(workspace.description == "One repo to start") + #expect(workspace.taskLinks == ["https://example.com/1"]) + #expect(workspace.repositories.map(\.role) == ["macOS app"]) + let loaded = try #require(ProjectWorkspace.load(from: rootURL)) + #expect(loaded.repositories.count == 1) + #expect(loaded.repositories[0].role == "macOS app") + } + + @Test func createRejectsEmptyRepositoryList() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appending(path: "prowl-create-empty-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: rootURL) } + + await #expect(throws: ProjectWorkspaceCreationError.notEnoughRepositories) { + try await ProjectWorkspace.create( + ProjectWorkspaceCreationRequest( + draft: ProjectWorkspaceCreationDraft(title: "Empty", rootURL: rootURL, repositories: []), + createdAt: Date() + ), + gitRunner: failingGitRunner + ) + } + #expect(!FileManager.default.fileExists(atPath: rootURL.path(percentEncoded: false))) + } + + @Test func updateRewritesMetadataAndPreservesUnknownKeys() async throws { + let rootURL = try makeTemporaryRoot(prefix: "prowl-update-meta") + defer { try? FileManager.default.removeItem(at: rootURL) } + try writeWorkspaceJSON( + """ + { + "schema_version": "prowl.workspace.v1", + "title": "Old Title", + "description": "Old description", + "task_links": ["OLD-1"], + "created_at": "2026-01-02T03:04:05Z", + "agent_guide": { "enabled": true }, + "repositories": [ + { + "id": "app", + "name": "App", + "role": "app", + "path": "app", + "source_kind": "existing_path", + "source_location": "/tmp/source/app", + "agent_notes": "keep me" + }, + { + "name": "API", + "path": "api", + "source_kind": "remote", + "source_location": "git@github.com:onevcat/api.git", + "base_ref": "origin/main", + "bootstrap": { "script_ids": ["install"] } + } + ] + } + """, + to: rootURL + ) + let existing = try #require(ProjectWorkspace.load(from: rootURL)) + var app = try #require(existing.repositories.first { $0.id == "app" }) + app.name = "Mac App" + app.role = nil + let api = try #require(existing.repositories.first { $0.id == "api" }) + let updatedAt = Date(timeIntervalSince1970: 1_700_000_000) + + let result = try await ProjectWorkspace.update( + ProjectWorkspaceUpdateRequest( + rootURL: rootURL, + title: " New Title ", + description: "New description", + taskLinks: ["https://example.com/2"], + members: [.existing(api), .existing(app)], + updatedAt: updatedAt + ), + gitRunner: failingGitRunner + ) + + #expect(result.cleanupFailures.isEmpty) + #expect(result.workspace.title == "New Title") + #expect(result.workspace.repositories.map(\.id) == ["api", "app"]) + #expect(result.workspace.createdAt == ISO8601DateFormatter().date(from: "2026-01-02T03:04:05Z")) + #expect(result.workspace.updatedAt == updatedAt) + + let data = try Data(contentsOf: ProjectWorkspace.metadataURL(for: rootURL)) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(object["title"] as? String == "New Title") + #expect(object["description"] as? String == "New description") + #expect(object["task_links"] as? [String] == ["https://example.com/2"]) + #expect((object["agent_guide"] as? [String: Any])?["enabled"] as? Bool == true) + let repositories = try #require(object["repositories"] as? [[String: Any]]) + #expect(repositories.map { $0["id"] as? String } == ["api", "app"]) + let savedApp = try #require(repositories.last) + #expect(savedApp["name"] as? String == "Mac App") + #expect(savedApp["role"] == nil) + #expect(savedApp["agent_notes"] as? String == "keep me") + // An entry that had no `id` on disk is matched through its path. + let savedAPI = try #require(repositories.first) + #expect(savedAPI["id"] as? String == "api") + #expect((savedAPI["bootstrap"] as? [String: Any])?["script_ids"] as? [String] == ["install"]) + + let reloaded = try #require(ProjectWorkspace.load(from: rootURL)) + #expect(reloaded.repositories.map(\.name) == ["API", "Mac App"]) + #expect(reloaded.repositories.map(\.role) == [nil, nil]) + } + + @Test func updateMaterializesAddedMemberAndRollsBackWithoutTouchingExisting() async throws { + let rootURL = try makeTemporaryRoot(prefix: "prowl-update-add") + let appURL = try makeTemporaryRoot(prefix: "prowl-app") + defer { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: appURL) + } + let existing = try await createLinkedWorkspace(rootURL: rootURL, appURL: appURL) + let appEntry = try #require(existing.repositories.first) + let cloneDestination = rootURL.appending(path: "api", directoryHint: .isDirectory) + let commands = LockIsolated<[ProjectWorkspaceGitCommand]>([]) + let failingRunner = ProjectWorkspaceGitRunner { command in + commands.withValue { $0.append(command) } + if command.arguments.first == "clone" { + try FileManager.default.createDirectory(at: cloneDestination, withIntermediateDirectories: true) + } + if command.arguments.contains("checkout") { + throw ProjectWorkspaceCreationError.gitCommandFailed(command: command.displayCommand, message: "boom") + } + } + let addedPlan = ProjectWorkspaceRepositoryPlan( + id: "api", + name: "API", + role: "backend", + path: "api", + sourceKind: .remote, + sourceLocation: "git@github.com:onevcat/api.git", + checkout: .useExistingRef("origin/main") + ) + + await #expect(throws: ProjectWorkspaceCreationError.self) { + try await ProjectWorkspace.update( + ProjectWorkspaceUpdateRequest( + rootURL: rootURL, + title: "Changed", + members: [.existing(appEntry), .added(addedPlan)], + updatedAt: Date() + ), + gitRunner: failingRunner + ) + } + + #expect(!FileManager.default.fileExists(atPath: cloneDestination.path(percentEncoded: false))) + let appLinkPath = appEntry.resolvedURL(relativeTo: rootURL).path(percentEncoded: false) + #expect( + (try? URL(fileURLWithPath: appLinkPath).resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true + ) + let unchanged = try #require(ProjectWorkspace.load(from: rootURL)) + #expect(unchanged.title == existing.title) + #expect(unchanged.repositories.map(\.id) == ["app"]) + + let succeedingRunner = ProjectWorkspaceGitRunner { command in + if command.arguments.first == "clone" { + try FileManager.default.createDirectory(at: cloneDestination, withIntermediateDirectories: true) + } + } + let result = try await ProjectWorkspace.update( + ProjectWorkspaceUpdateRequest( + rootURL: rootURL, + title: "Changed", + members: [.existing(appEntry), .added(addedPlan)], + updatedAt: Date() + ), + gitRunner: succeedingRunner + ) + #expect(result.workspace.repositories.map(\.id) == ["app", "api"]) + #expect(result.workspace.repositories.last?.role == "backend") + #expect(result.workspace.repositories.last?.baseRef == "origin/main") + #expect(ProjectWorkspace.load(from: rootURL)?.title == "Changed") + } + + @Test func updateSuffixesAddedPathOccupiedByRemovedMemberLeftOnDisk() async throws { + let rootURL = try makeTemporaryRoot(prefix: "prowl-update-suffix") + let appURL = try makeTemporaryRoot(prefix: "prowl-app") + let otherURL = try makeTemporaryRoot(prefix: "prowl-other") + defer { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: appURL) + try? FileManager.default.removeItem(at: otherURL) + } + let existing = try await createLinkedWorkspace(rootURL: rootURL, appURL: appURL) + let appEntry = try #require(existing.repositories.first) + + let result = try await ProjectWorkspace.update( + ProjectWorkspaceUpdateRequest( + rootURL: rootURL, + title: existing.title, + members: [ + .added( + ProjectWorkspaceRepositoryPlan( + id: "app-again", + name: "App", + path: "app", + sourceKind: .localRepository, + sourceLocation: otherURL.path(percentEncoded: false), + checkout: .link + )) + ], + removals: [ProjectWorkspaceRepositoryRemoval(entry: appEntry, deleteFiles: false)], + updatedAt: Date() + ), + gitRunner: failingGitRunner + ) + + #expect(result.workspace.repositories.map(\.path) == ["app-2"]) + #expect(result.cleanupFailures.isEmpty) + #expect(result.completedRemovals.isEmpty) + // The removed member's symlink was kept because deleteFiles was false. + #expect(FileManager.default.fileExists(atPath: rootURL.appending(path: "app").path(percentEncoded: false))) + } + + @Test func updateRemovesSymlinkForLinkedMemberWhenDeletingFiles() async throws { + let rootURL = try makeTemporaryRoot(prefix: "prowl-update-unlink") + let appURL = try makeTemporaryRoot(prefix: "prowl-app") + let apiURL = try makeTemporaryRoot(prefix: "prowl-api") + defer { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: appURL) + try? FileManager.default.removeItem(at: apiURL) + } + let existing = try await createLinkedWorkspace(rootURL: rootURL, appURL: appURL, apiURL: apiURL) + let appEntry = try #require(existing.repositories.first { $0.id == "app" }) + let apiEntry = try #require(existing.repositories.first { $0.id == "api" }) + + let result = try await ProjectWorkspace.update( + ProjectWorkspaceUpdateRequest( + rootURL: rootURL, + title: existing.title, + members: [.existing(apiEntry)], + removals: [ProjectWorkspaceRepositoryRemoval(entry: appEntry, deleteFiles: true)], + updatedAt: Date() + ), + gitRunner: failingGitRunner + ) + + #expect(result.cleanupFailures.isEmpty) + #expect(result.completedRemovals.map(\.id) == ["app"]) + let appLinkPath = rootURL.appending(path: "app").path(percentEncoded: false) + #expect(!FileManager.default.fileExists(atPath: appLinkPath)) + #expect( + (try? URL(fileURLWithPath: appLinkPath).resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) != true + ) + // The linked source repository is untouched. + #expect(FileManager.default.fileExists(atPath: appURL.path(percentEncoded: false))) + #expect(ProjectWorkspace.load(from: rootURL)?.repositories.map(\.id) == ["api"]) + } + + @Test func updateUnregistersWorktreeMemberAndReportsFailedCleanup() async throws { + let rootURL = try makeTemporaryRoot(prefix: "prowl-update-worktree") + defer { try? FileManager.default.removeItem(at: rootURL) } + let worktreeURL = rootURL.appending(path: "api", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: worktreeURL, withIntermediateDirectories: true) + let keptURL = rootURL.appending(path: "app", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: keptURL, withIntermediateDirectories: true) + try writeWorkspaceJSON( + """ + { + "title": "Worktrees", + "repositories": [ + { "id": "app", "name": "App", "path": "app", "source_kind": "remote", + "source_location": "git@github.com:onevcat/app.git" }, + { "id": "api", "name": "API", "path": "api", "source_kind": "local_repository", + "source_location": "/tmp/source/api", "branch_name": "feature/api" } + ] + } + """, + to: rootURL + ) + let existing = try #require(ProjectWorkspace.load(from: rootURL)) + let appEntry = try #require(existing.repositories.first { $0.id == "app" }) + let apiEntry = try #require(existing.repositories.first { $0.id == "api" }) + let commands = LockIsolated<[ProjectWorkspaceGitCommand]>([]) + let worktreePath = normalizedPath(worktreeURL) + + let failed = try await ProjectWorkspace.update( + ProjectWorkspaceUpdateRequest( + rootURL: rootURL, + title: "Worktrees", + members: [.existing(appEntry)], + removals: [ProjectWorkspaceRepositoryRemoval(entry: apiEntry, deleteFiles: true, deleteBranch: true)], + updatedAt: Date() + ), + gitRunner: ProjectWorkspaceGitRunner { command in + commands.withValue { $0.append(command) } + throw ProjectWorkspaceCreationError.gitCommandFailed(command: command.displayCommand, message: "locked") + } + ) + + #expect( + commands.value.map(\.arguments) == [ + ["-C", "/tmp/source/api", "worktree", "remove", "--force", worktreePath] + ]) + #expect(failed.cleanupFailures.map(\.entryID) == ["api"]) + #expect(failed.completedRemovals.isEmpty) + // The metadata is already committed without the entry; the folder stays. + #expect(ProjectWorkspace.load(from: rootURL)?.repositories.map(\.id) == ["app"]) + #expect(FileManager.default.fileExists(atPath: worktreePath)) + + // A second attempt after the metadata dropped the entry is a plain + // "remove a folder Prowl does not know" — the removal list is what drives it. + let removed = try await ProjectWorkspace.update( + ProjectWorkspaceUpdateRequest( + rootURL: rootURL, + title: "Worktrees", + members: [.existing(appEntry)], + removals: [ProjectWorkspaceRepositoryRemoval(entry: apiEntry, deleteFiles: true, deleteBranch: true)], + updatedAt: Date() + ), + gitRunner: ProjectWorkspaceGitRunner { _ in + try FileManager.default.removeItem(at: worktreeURL) + } + ) + #expect(removed.cleanupFailures.isEmpty) + #expect(removed.completedRemovals.map(\.id) == ["api"]) + #expect(removed.completedRemovals.first?.deleteBranch == true) + #expect(!FileManager.default.fileExists(atPath: worktreePath)) + } + + @Test func updateDeletesCloneFolderAndNeverTouchesPathsOutsideRoot() async throws { + let rootURL = try makeTemporaryRoot(prefix: "prowl-update-clone") + let outsideURL = try makeTemporaryRoot(prefix: "prowl-outside") + defer { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: outsideURL) + } + let cloneURL = rootURL.appending(path: "api", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: cloneURL, withIntermediateDirectories: true) + let outsidePath = outsideURL.standardizedFileURL.path(percentEncoded: false) + try writeWorkspaceJSON( + """ + { + "title": "Clones", + "repositories": [ + { "id": "api", "name": "API", "path": "api", "source_kind": "remote", + "source_location": "git@github.com:onevcat/api.git" }, + { "id": "ext", "name": "External", "path": "\(outsidePath)", "source_kind": "existing_path" }, + { "id": "keep", "name": "Keep", "path": "keep", "source_kind": "existing_path" } + ] + } + """, + to: rootURL + ) + let existing = try #require(ProjectWorkspace.load(from: rootURL)) + let apiEntry = try #require(existing.repositories.first { $0.id == "api" }) + let extEntry = try #require(existing.repositories.first { $0.id == "ext" }) + let keepEntry = try #require(existing.repositories.first { $0.id == "keep" }) + + let result = try await ProjectWorkspace.update( + ProjectWorkspaceUpdateRequest( + rootURL: rootURL, + title: "Clones", + members: [.existing(keepEntry)], + removals: [ + ProjectWorkspaceRepositoryRemoval(entry: apiEntry, deleteFiles: true), + ProjectWorkspaceRepositoryRemoval(entry: extEntry, deleteFiles: true), + ], + updatedAt: Date() + ), + gitRunner: failingGitRunner + ) + + #expect(!FileManager.default.fileExists(atPath: cloneURL.path(percentEncoded: false))) + #expect(FileManager.default.fileExists(atPath: outsidePath)) + #expect(result.cleanupFailures.map(\.entryID) == ["ext"]) + #expect(result.completedRemovals.map(\.id) == ["api"]) + } + + @Test func updateRejectsInvalidRequests() async throws { + let rootURL = try makeTemporaryRoot(prefix: "prowl-update-invalid") + let appURL = try makeTemporaryRoot(prefix: "prowl-app") + defer { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: appURL) + } + let missingRoot = FileManager.default.temporaryDirectory + .appending(path: "prowl-missing-\(UUID().uuidString)") + + await #expect(throws: ProjectWorkspaceCreationError.self) { + try await ProjectWorkspace.update( + ProjectWorkspaceUpdateRequest( + rootURL: missingRoot, title: "X", members: [.existing(ProjectWorkspaceRepositoryEntry(id: "a", path: "a"))], + updatedAt: Date()), + gitRunner: failingGitRunner + ) + } + + let existing = try await createLinkedWorkspace(rootURL: rootURL, appURL: appURL) + let appEntry = try #require(existing.repositories.first) + await #expect(throws: ProjectWorkspaceCreationError.missingTitle) { + try await ProjectWorkspace.update( + ProjectWorkspaceUpdateRequest(rootURL: rootURL, title: " ", members: [.existing(appEntry)], updatedAt: Date()), + gitRunner: failingGitRunner + ) + } + await #expect(throws: ProjectWorkspaceCreationError.notEnoughRepositories) { + try await ProjectWorkspace.update( + ProjectWorkspaceUpdateRequest( + rootURL: rootURL, title: "Title", members: [], + removals: [ProjectWorkspaceRepositoryRemoval(entry: appEntry, deleteFiles: true)], + updatedAt: Date()), + gitRunner: failingGitRunner + ) + } + // Validation failures never reach the cleanup step. + #expect(FileManager.default.fileExists(atPath: rootURL.appending(path: "app").path(percentEncoded: false))) + } + + // MARK: - Helpers + + private var failingGitRunner: ProjectWorkspaceGitRunner { + ProjectWorkspaceGitRunner { command in + throw ProjectWorkspaceCreationError.gitCommandFailed(command: command.displayCommand, message: "unexpected") + } + } + + private func createLinkedWorkspace(rootURL: URL, appURL: URL, apiURL: URL? = nil) async throws + -> ProjectWorkspace + { + var plans = [ + ProjectWorkspaceRepositoryPlan( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath, + sourceLocation: appURL.path(percentEncoded: false), + checkout: .link + ) + ] + if let apiURL { + plans.append( + ProjectWorkspaceRepositoryPlan( + id: "api", + name: "API", + path: "api", + sourceKind: .existingPath, + sourceLocation: apiURL.path(percentEncoded: false), + checkout: .link + )) + } + return try await ProjectWorkspace.create( + ProjectWorkspaceCreationRequest( + draft: ProjectWorkspaceCreationDraft(title: "Linked", rootURL: rootURL, repositories: plans), + createdAt: Date(timeIntervalSince1970: 1_000) + ), + gitRunner: failingGitRunner + ) + } + + /// Directory URLs render with a trailing slash; git commands receive the + /// plain path. + private func normalizedPath(_ url: URL) -> String { + var path = url.standardizedFileURL.path(percentEncoded: false) + while path.count > 1, path.hasSuffix("/") { + path.removeLast() + } + return path + } + + private func makeTemporaryRoot(prefix: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appending(path: "\(prefix)-\(UUID().uuidString)", directoryHint: .isDirectory) + .standardizedFileURL + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func writeWorkspaceJSON(_ json: String, to rootURL: URL) throws { + let metadataDirectoryURL = rootURL.appending(path: ProjectWorkspace.metadataDirectoryName) + try FileManager.default.createDirectory(at: metadataDirectoryURL, withIntermediateDirectories: true) + try Data(json.utf8).write(to: ProjectWorkspace.metadataURL(for: rootURL)) + } +} -- 2.51.2 From e11ebe34c3f8f01837ccd9c1b992d1c16e658920 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 25 Sep 2026 17:06:40 +0800 Subject: [PATCH 03/15] Turn the workspace creation prompt into a create/edit editor WorkspaceCreationPromptFeature becomes WorkspaceEditorFeature with a mode. Edit mode pre-fills title, description, task links, and the existing members (name and role editable, provenance read-only, move up/down, staged removal with file and branch deletion opt-ins) and adds new members with the unchanged creation rows. RepositoriesFeature gains the workspaceEditing action family: the editor re-reads the metadata from disk, saves through ProjectWorkspace.update, deletes opted-in branches through the guarded git client path, reloads, toasts, and reports cleanup failures. --- supacode/App/ContentView.swift | 4 +- .../App/Reducer/AppFeature+Support.swift | 2 +- .../RepositoriesFeature+CoreReducer.swift | 22 +- ...epositoriesFeature+WorkspaceCreation.swift | 30 +- ...RepositoriesFeature+WorkspaceEditing.swift | 147 ++++ .../Reducer/RepositoriesFeature.swift | 24 +- .../Reducer/WorkspaceEditorFeature.swift | 294 +++++++- .../Views/WorkspaceEditorView.swift | 658 ++++++++++++++---- .../AppFeatureCommandPaletteTests.swift | 4 +- supacodeTests/ProjectWorkspaceTests.swift | 18 +- supacodeTests/RepositoriesFeatureTests.swift | 180 ++--- ...sitoriesFeatureWorkspaceEditingTests.swift | 326 +++++++++ .../WorkspaceEditorFeatureTests.swift | 315 +++++++++ 13 files changed, 1737 insertions(+), 287 deletions(-) create mode 100644 supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceEditing.swift create mode 100644 supacodeTests/RepositoriesFeatureWorkspaceEditingTests.swift create mode 100644 supacodeTests/WorkspaceEditorFeatureTests.swift diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index 96c0f362..8b2ab873 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -95,9 +95,9 @@ struct ContentView: View { promptStore in WorktreeCreationPromptView(store: promptStore) } - .sheet(item: $repositoriesStore.scope(state: \.workspaceCreationPrompt, action: \.workspaceCreationPrompt)) { + .sheet(item: $repositoriesStore.scope(state: \.workspaceEditor, action: \.workspaceEditor)) { promptStore in - WorkspaceCreationPromptView(store: promptStore) + WorkspaceEditorView(store: promptStore) } .sheet(item: renameBranchPromptRequest) { request in RenameBranchPromptView( diff --git a/supacode/Features/App/Reducer/AppFeature+Support.swift b/supacode/Features/App/Reducer/AppFeature+Support.swift index e659d9fd..4876dc5b 100644 --- a/supacode/Features/App/Reducer/AppFeature+Support.swift +++ b/supacode/Features/App/Reducer/AppFeature+Support.swift @@ -82,7 +82,7 @@ extension AppFeature.State { || alert != nil || repositories.isOpenPanelPresented || repositories.worktreeCreationPrompt != nil - || repositories.workspaceCreationPrompt != nil + || repositories.workspaceEditor != nil || repositories.pendingRenameBranchRequest != nil || repositories.deleteWorktreeConfirmation != nil || repositories.removeWorkspaceConfirmation != nil diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index 099b6a16..4fe9975e 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -12,7 +12,8 @@ extension RepositoriesFeature { switch action { case .worktreeCreation, .worktreeLifecycle, .worktreeOrdering, .githubIntegration, .repositoryManagement, - .workspaceCreation: + .workspaceCreation, + .workspaceEditing: return .none case .activeAgents(.entryTapped(let id)), @@ -833,19 +834,28 @@ extension RepositoriesFeature { case .worktreeCreationPrompt: return .none - case .workspaceCreationPrompt(.presented(.delegate(.cancel))): + case .workspaceEditor(.presented(.delegate(.cancel))): + if state.workspaceEditor?.mode.isEditing == true { + return .send(.workspaceEditing(.promptCanceled)) + } return .send(.workspaceCreation(.promptCanceled)) - case .workspaceCreationPrompt(.presented(.delegate(.baseRefSourceChanged(let repositoryID)))): + case .workspaceEditor(.presented(.delegate(.baseRefSourceChanged(let repositoryID)))): return .send(.workspaceCreation(.refreshBaseRefs(repositoryID))) - case .workspaceCreationPrompt(.presented(.delegate(.submit(let draft)))): + case .workspaceEditor(.presented(.delegate(.submit(.create(let draft))))): return .send(.workspaceCreation(.createWorkspace(draft))) - case .workspaceCreationPrompt(.dismiss): + case .workspaceEditor(.presented(.delegate(.submit(.update(let request))))): + return .send(.workspaceEditing(.saveWorkspace(request))) + + case .workspaceEditor(.dismiss): + if state.workspaceEditor?.mode.isEditing == true { + return .send(.workspaceEditing(.promptDismissed)) + } return .send(.workspaceCreation(.promptDismissed)) - case .workspaceCreationPrompt: + case .workspaceEditor: return .none case .alert(.presented(.confirmArchiveWorktree(let worktreeID, let repositoryID))): diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift index 34759bca..3ee5f15a 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift @@ -39,7 +39,7 @@ extension RepositoriesFeature { // the reducer body performs no filesystem I/O. let folderName = ProjectWorkspace.defaultWorkspaceFolderName(for: title) let requestedRootPath = ProjectWorkspace.workspaceRootPath(folderName: folderName, suffix: nil) - state.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( + state.workspaceEditor = WorkspaceEditorFeature.State( repositories: [], title: title, rootPath: requestedRootPath, @@ -53,15 +53,15 @@ extension RepositoriesFeature { case .defaultRootPathResolved(let path, let requestedRootPath): // Only adopt the de-duplicated path if the user has not edited the field // since the prompt opened. - guard state.workspaceCreationPrompt?.rootPath == requestedRootPath else { + guard state.workspaceEditor?.rootPath == requestedRootPath else { return .none } - state.workspaceCreationPrompt?.rootPath = path + state.workspaceEditor?.rootPath = path return .none case .promptCanceled, .promptDismissed: - let wasCreating = state.workspaceCreationPrompt?.isCreating == true - state.workspaceCreationPrompt = nil + let wasCreating = state.workspaceEditor?.isSaving == true + state.workspaceEditor = nil guard wasCreating else { return .cancel(id: CancelID.workspaceCreation) } @@ -71,7 +71,7 @@ extension RepositoriesFeature { ) case .refreshBaseRefs(let repositoryID): - guard let repository = state.workspaceCreationPrompt?.repositories[id: repositoryID] else { + guard let repository = state.workspaceEditor?.repositories[id: repositoryID] else { return .none } return workspaceBaseRefsEffect(for: [repository]) @@ -93,8 +93,8 @@ extension RepositoriesFeature { return .none case .createWorkspace(let draft): - state.workspaceCreationPrompt?.isCreating = true - state.workspaceCreationPrompt?.validationMessage = nil + state.workspaceEditor?.isSaving = true + state.workspaceEditor?.validationMessage = nil let request = ProjectWorkspaceCreationRequest(draft: draft, createdAt: now) let gitRunner = Self.workspaceGitRunner(shellClient: shellClient) return .run { send in @@ -114,16 +114,16 @@ extension RepositoriesFeature { case .workspaceCreated(let rootURL): analyticsClient.capture("workspace_created", [String: Any]?.none) - state.workspaceCreationPrompt = nil + state.workspaceEditor = nil return .merge( .send(.showToast(.success(String(localized: "Workspace created")))), .send(.repositoryManagement(.openRepositories([rootURL]))) ) case .workspaceCreationFailed(let message): - if state.workspaceCreationPrompt != nil { - state.workspaceCreationPrompt?.isCreating = false - state.workspaceCreationPrompt?.validationMessage = message + if state.workspaceEditor != nil { + state.workspaceEditor?.isSaving = false + state.workspaceEditor?.validationMessage = message } else { state.alert = messageAlert(title: String(localized: "Unable to create workspace"), message: message) } @@ -155,14 +155,14 @@ extension RepositoriesFeature { sourceLocation: String, result: WorkspaceBaseRefsResult ) { - guard var repository = state.workspaceCreationPrompt?.repositories[id: repositoryID], + guard var repository = state.workspaceEditor?.repositories[id: repositoryID], repository.sourceKind == sourceKind, repository.sourceLocation == sourceLocation else { return } if let errorMessage = result.errorMessage { - state.workspaceCreationPrompt?.validationMessage = errorMessage + state.workspaceEditor?.validationMessage = errorMessage } let baseRef = trimmedNonEmpty(repository.baseRef) let baseRefOptions = ProjectWorkspaceCreationRepository.normalizedBaseRefOptions(result.options) @@ -172,7 +172,7 @@ extension RepositoriesFeature { } else { repository.baseRef = result.defaultBaseRef } - state.workspaceCreationPrompt?.repositories[id: repositoryID] = repository + state.workspaceEditor?.repositories[id: repositoryID] = repository } static func workspaceGitRunner( diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceEditing.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceEditing.swift new file mode 100644 index 00000000..69b8d915 --- /dev/null +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceEditing.swift @@ -0,0 +1,147 @@ +import ComposableArchitecture +import Foundation + +nonisolated private let workspaceEditingLog = SupaLogger("workspace") + +extension RepositoriesFeature { + func reduceWorkspaceEditing( + state: inout State, + action: WorkspaceEditingAction + ) -> Effect { + switch action { + case .promptRequested(let repositoryID, let removingChildID): + guard let repository = state.repositories[id: repositoryID], + let snapshot = repository.workspace, + !state.removingRepositoryIDs.contains(repositoryID), + state.workspaceEditor == nil + else { + return .none + } + let rootURL = repository.rootURL + return .run { send in + // Re-read the file so edits made outside Prowl since the last reload + // are what the editor shows; the loaded snapshot is the fallback. + let workspace = ProjectWorkspace.load(from: rootURL) ?? snapshot + await send( + .workspaceEditing( + .promptLoaded(repositoryID, workspace, removingChildID: removingChildID))) + } + + case .promptLoaded(let repositoryID, let workspace, let removingChildID): + guard let repository = state.repositories[id: repositoryID], state.workspaceEditor == nil + else { + return .none + } + let uuid = uuid + var editor = WorkspaceEditorFeature.State( + editing: workspace, + rootURL: repository.rootURL, + repositoryID: repositoryID, + openedRepositoryCandidates: state.workspaceCreationCandidates, + taskLinkIDs: { _ in uuid().uuidString } + ) + if let removingChildID, + let entry = workspace.repositories.first(where: { entry in + entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) + == removingChildID + }) + { + editor.existingRepositories[id: entry.id]?.removal = .init() + } + state.workspaceEditor = editor + return .none + + case .promptCanceled, .promptDismissed: + // Cancel is disabled while saving (cleanup runs after the metadata is + // committed, so an interrupted save would strand folders); the guard + // covers a stray cancel. `.dismiss` has already been applied by `ifLet`. + guard state.workspaceEditor?.isSaving != true else { + return .none + } + state.workspaceEditor = nil + return .none + + case .saveWorkspace(let request): + guard case .edit(let repositoryID)? = state.workspaceEditor?.mode else { + return .none + } + state.workspaceEditor?.isSaving = true + state.workspaceEditor?.validationMessage = nil + return saveWorkspaceEffect(request, repositoryID: repositoryID) + + case .workspaceSaved(_, let cleanupFailures): + analyticsClient.capture("workspace_edited", [String: Any]?.none) + state.workspaceEditor = nil + if !cleanupFailures.isEmpty { + state.alert = messageAlert( + title: String(localized: "Some folders were left in place"), + message: cleanupFailures.map { "\($0.entryName): \($0.message)" } + .joined(separator: "\n") + ) + } + return .merge( + .send(.showToast(.success(String(localized: "Workspace saved")))), + loadRepositories(fallbackRoots: state.repositoryRoots, animated: true) + ) + + case .workspaceSaveFailed(let message): + guard state.workspaceEditor != nil else { + // The sheet went away mid-save; the error still needs a home. + state.alert = messageAlert(title: String(localized: "Unable to save workspace"), message: message) + return .none + } + state.workspaceEditor?.isSaving = false + state.workspaceEditor?.validationMessage = message + return .none + } + } + + /// Not cancellable on purpose: member cleanup runs after the metadata is + /// committed, so an interrupted save could leave folders the metadata no + /// longer references. + private func saveWorkspaceEffect( + _ request: ProjectWorkspaceUpdateRequest, + repositoryID: Repository.ID + ) -> Effect { + let gitRunner = Self.workspaceGitRunner(shellClient: shellClient) + let gitClient = gitClient + return .run { send in + do { + let result = try await ProjectWorkspace.update(request, gitRunner: gitRunner) + // Branch deletion goes through the guarded entry point so protected + // branches survive, exactly like workspace removal. + for removal in result.completedRemovals where removal.deleteBranch { + guard let sourceLocation = removal.entry.sourceLocation, + let branchName = removal.entry.branchName + else { + continue + } + let outcome = try? await gitClient.deleteLocalBranch( + branchName, + URL(fileURLWithPath: sourceLocation), + true + ) + if case .protected? = outcome { + workspaceEditingLog.warning( + "Skipped deleting protected branch \(branchName) in \(sourceLocation)") + } + } + await send( + .workspaceEditing( + .workspaceSaved(repositoryID, cleanupFailures: result.cleanupFailures))) + } catch { + workspaceEditingLog.warning("Workspace save failed: \(error.localizedDescription)") + await send(.workspaceEditing(.workspaceSaveFailed(error.localizedDescription))) + } + } + } + + var workspaceEditingReducer: some Reducer { + Reduce { state, action in + guard case .workspaceEditing(let action) = action else { + return .none + } + return reduceWorkspaceEditing(state: &state, action: action) + } + } +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index d3e126bb..90cca524 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -284,6 +284,19 @@ struct RepositoriesFeature { case workspaceCreationFailed(String) } + @CasePathable + enum WorkspaceEditingAction: Equatable { + /// Opens the editor on an existing workspace. `removingChildID` is a + /// child working-directory path (sidebar row id) to pre-mark for removal. + case promptRequested(Repository.ID, removingChildID: String?) + case promptLoaded(Repository.ID, ProjectWorkspace, removingChildID: String?) + case promptCanceled + case promptDismissed + case saveWorkspace(ProjectWorkspaceUpdateRequest) + case workspaceSaved(Repository.ID, cleanupFailures: [ProjectWorkspaceCleanupFailure]) + case workspaceSaveFailed(String) + } + @ObservableState struct State: Equatable { var repositories: IdentifiedArrayOf = [] @@ -386,7 +399,7 @@ struct RepositoriesFeature { var workflowRoleBadgesBySurfaceID: [UUID: String] = [:] @Shared(.appStorage("sidebarCollapsedRepositoryIDs")) var collapsedRepositoryIDs: [Repository.ID] = [] @Presents var worktreeCreationPrompt: WorktreeCreationPromptFeature.State? - @Presents var workspaceCreationPrompt: WorkspaceCreationPromptFeature.State? + @Presents var workspaceEditor: WorkspaceEditorFeature.State? @Presents var alert: AlertState? } @@ -420,6 +433,7 @@ struct RepositoriesFeature { case githubIntegration(GithubIntegrationAction) case repositoryManagement(RepositoryManagementAction) case workspaceCreation(WorkspaceCreationAction) + case workspaceEditing(WorkspaceEditingAction) case activeAgents(ActiveAgentsFeature.Action) case task case repositorySnapshotLoaded([Repository]?) @@ -481,7 +495,7 @@ struct RepositoriesFeature { case showToast(StatusToast) case dismissToast case worktreeCreationPrompt(PresentationAction) - case workspaceCreationPrompt(PresentationAction) + case workspaceEditor(PresentationAction) case alert(PresentationAction) case delegate(Delegate) } @@ -583,6 +597,7 @@ struct RepositoriesFeature { githubIntegrationReducer repositoryManagementReducer workspaceCreationReducer + workspaceEditingReducer Scope(state: \.activeAgents, action: \.activeAgents) { ActiveAgentsFeature() } @@ -604,8 +619,8 @@ struct RepositoriesFeature { .ifLet(\.$worktreeCreationPrompt, action: \.worktreeCreationPrompt) { WorktreeCreationPromptFeature() } - .ifLet(\.$workspaceCreationPrompt, action: \.workspaceCreationPrompt) { - WorkspaceCreationPromptFeature() + .ifLet(\.$workspaceEditor, action: \.workspaceEditor) { + WorkspaceEditorFeature() } } @@ -618,3 +633,4 @@ struct RepositoriesFeature { // - RepositoriesFeature+GithubIntegration.swift // - RepositoriesFeature+RepositoryManagement.swift // - RepositoriesFeature+WorkspaceCreation.swift +// - RepositoriesFeature+WorkspaceEditing.swift diff --git a/supacode/Features/Repositories/Reducer/WorkspaceEditorFeature.swift b/supacode/Features/Repositories/Reducer/WorkspaceEditorFeature.swift index 0e4e9796..2a2d4a89 100644 --- a/supacode/Features/Repositories/Reducer/WorkspaceEditorFeature.swift +++ b/supacode/Features/Repositories/Reducer/WorkspaceEditorFeature.swift @@ -2,23 +2,90 @@ import ComposableArchitecture import Foundation import IdentifiedCollections +/// A member that already exists in the edited workspace. Its source, path and +/// checkout are read-only provenance; only the display name and role can be +/// edited, and the member can be marked for removal until Save. +struct WorkspaceEditorExistingRepository: Equatable, Identifiable, Sendable { + struct Removal: Equatable, Sendable { + var deleteFiles = false + var deleteBranch = false + } + + let entry: ProjectWorkspaceRepositoryEntry + var name: String + var role: String + var removal: Removal? + + var id: String { entry.id } + + var isMarkedForRemoval: Bool { removal != nil } + + /// Branch deletion is offered on the same terms as workspace removal: a + /// worktree checkout with a recorded branch and source repository. + var offersBranchDeletion: Bool { + entry.sourceKind != .remote && entry.branchName != nil && entry.sourceLocation != nil + } + + init(entry: ProjectWorkspaceRepositoryEntry) { + self.entry = entry + name = entry.name + role = entry.role ?? "" + } + + /// The entry as it should be saved, carrying the edited name and role. + var editedEntry: ProjectWorkspaceRepositoryEntry { + var entry = entry + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + entry.name = trimmedName.isEmpty ? self.entry.name : trimmedName + let trimmedRole = role.trimmingCharacters(in: .whitespacesAndNewlines) + entry.role = trimmedRole.isEmpty ? nil : trimmedRole + return entry + } +} + +struct WorkspaceTaskLinkDraft: Equatable, Identifiable, Sendable { + let id: String + var value: String +} + +enum WorkspaceEditorSubmission: Equatable, Sendable { + case create(ProjectWorkspaceCreationDraft) + case update(ProjectWorkspaceUpdateRequest) +} + @Reducer -struct WorkspaceCreationPromptFeature { +struct WorkspaceEditorFeature { private enum CancelID { - static let remoteRepositoryPromptLoad = "workspaceCreationPrompt.remoteRepositoryPromptLoad" + static let remoteRepositoryPromptLoad = "workspaceEditor.remoteRepositoryPromptLoad" + } + + enum Mode: Equatable, Sendable { + case create + case edit(repositoryID: Repository.ID) + + var isEditing: Bool { + if case .edit = self { return true } + return false + } } @ObservableState struct State: Equatable { + var mode: Mode + /// Members that already exist on disk (edit mode only), in saved order. + var existingRepositories: IdentifiedArrayOf + /// Members added in this session; materialized on Create / Save. var repositories: IdentifiedArrayOf var openedRepositoryCandidates: IdentifiedArrayOf var title: String + var description: String + var taskLinks: IdentifiedArrayOf var rootPath: String var isRootPathDirty = false var validationMessage: String? var validationTarget: ValidationTarget? var validationRequestID = 0 - var isCreating = false + var isSaving = false var remoteRepositoryPrompt: RemoteRepositoryPromptState? var availableOpenedRepositories: [ProjectWorkspaceCreationRepository] { @@ -29,21 +96,67 @@ struct WorkspaceCreationPromptFeature { PathPolicy.normalizePath(rootPath, resolvingSymlinks: false) ?? rootPath } + /// Members that will exist after Create / Save. + var remainingRepositoryCount: Int { + existingRepositories.filter { !$0.isMarkedForRemoval }.count + repositories.count + } + + var hasPendingRemovals: Bool { + existingRepositories.contains(where: \.isMarkedForRemoval) + } + init( repositories: [ProjectWorkspaceCreationRepository], title: String, rootPath: String, openedRepositoryCandidates: [ProjectWorkspaceCreationRepository] = [] ) { + mode = .create + existingRepositories = [] self.repositories = IdentifiedArray(repositories, uniquingIDsWith: { current, _ in current }) self.openedRepositoryCandidates = IdentifiedArray( openedRepositoryCandidates, uniquingIDsWith: { current, _ in current } ) self.title = title + description = "" + taskLinks = [] self.rootPath = rootPath } + init( + editing workspace: ProjectWorkspace, + rootURL: URL, + repositoryID: Repository.ID, + openedRepositoryCandidates: [ProjectWorkspaceCreationRepository] = [], + taskLinkIDs: (Int) -> String = { "task-link-\($0)" } + ) { + mode = .edit(repositoryID: repositoryID) + existingRepositories = IdentifiedArray( + workspace.repositories.map(WorkspaceEditorExistingRepository.init(entry:)), + uniquingIDsWith: { current, _ in current } + ) + repositories = [] + self.openedRepositoryCandidates = IdentifiedArray( + openedRepositoryCandidates, + uniquingIDsWith: { current, _ in current } + ) + title = workspace.title + description = workspace.description + taskLinks = IdentifiedArray( + workspace.taskLinks.enumerated().map { index, link in + WorkspaceTaskLinkDraft(id: taskLinkIDs(index), value: link) + }, + uniquingIDsWith: { current, _ in current } + ) + var path = rootURL.standardizedFileURL.path(percentEncoded: false) + while path.count > 1, path.hasSuffix("/") { + path.removeLast() + } + rootPath = path + isRootPathDirty = true + } + mutating func clearValidation() { validationMessage = nil validationTarget = nil @@ -60,6 +173,7 @@ struct WorkspaceCreationPromptFeature { case title case rootPath case repository(Repository.ID, RepositoryField) + case existingRepository(String) } enum RepositoryField: Equatable, Sendable { @@ -87,6 +201,10 @@ struct WorkspaceCreationPromptFeature { enum Action: BindableAction, Equatable { case binding(BindingAction) case titleChanged(String) + case descriptionChanged(String) + case addTaskLinkButtonTapped + case taskLinkChanged(String, String) + case removeTaskLink(String) case rootPathChanged(String) case automaticRootPathResolved(path: String, requestedRootPath: String) case addOpenedRepository(Repository.ID) @@ -103,15 +221,24 @@ struct WorkspaceCreationPromptFeature { case repositorySourceKindChanged(Repository.ID, ProjectWorkspaceRepositorySourceKind) case repositoryCheckoutModeChanged(Repository.ID, ProjectWorkspaceRepositoryCheckoutMode) case repositoryNameChanged(Repository.ID, String) + case repositoryRoleChanged(Repository.ID, String) case repositoryPathChanged(Repository.ID, String) case repositorySourceChosen(Repository.ID, String) case repositorySourceLocationChanged(Repository.ID, String) case repositoryBranchNameChanged(Repository.ID, String) case repositoryBaseRefChanged(Repository.ID, String) case repositoryResetLocalBranchChanged(Repository.ID, Bool) + case existingRepositoryNameChanged(String, String) + case existingRepositoryRoleChanged(String, String) + case existingRepositoryMarkedForRemoval(String) + case existingRepositoryRemovalUndone(String) + case existingRepositoryDeleteFilesChanged(String, Bool) + case existingRepositoryDeleteBranchChanged(String, Bool) + case existingRepositoryMovedUp(String) + case existingRepositoryMovedDown(String) case rootPathChosen(String) case cancelButtonTapped - case createButtonTapped + case submitButtonTapped case delegate(Delegate) } @@ -119,10 +246,11 @@ struct WorkspaceCreationPromptFeature { enum Delegate: Equatable { case baseRefSourceChanged(Repository.ID) case cancel - case submit(ProjectWorkspaceCreationDraft) + case submit(WorkspaceEditorSubmission) } @Dependency(\.uuid) var uuid + @Dependency(\.date.now) var now @Dependency(GitClientDependency.self) var gitClient var body: some Reducer { @@ -136,7 +264,7 @@ struct WorkspaceCreationPromptFeature { case .titleChanged(let title): state.title = title state.clearValidation() - guard !state.isRootPathDirty else { + guard !state.mode.isEditing, !state.isRootPathDirty else { return .none } let folderName = ProjectWorkspace.defaultWorkspaceFolderName(for: title) @@ -148,6 +276,26 @@ struct WorkspaceCreationPromptFeature { .automaticRootPathResolved(path: resolved, requestedRootPath: requestedRootPath)) } + case .descriptionChanged(let description): + state.description = description + state.clearValidation() + return .none + + case .addTaskLinkButtonTapped: + state.taskLinks.append(WorkspaceTaskLinkDraft(id: uuid().uuidString, value: "")) + state.clearValidation() + return .none + + case .taskLinkChanged(let linkID, let value): + state.taskLinks[id: linkID]?.value = value + state.clearValidation() + return .none + + case .removeTaskLink(let linkID): + state.taskLinks.remove(id: linkID) + state.clearValidation() + return .none + case .rootPathChanged(let rootPath): state.rootPath = rootPath state.isRootPathDirty = true @@ -339,6 +487,11 @@ struct WorkspaceCreationPromptFeature { state.clearValidation() return .none + case .repositoryRoleChanged(let repositoryID, let role): + state.repositories[id: repositoryID]?.role = role + state.clearValidation() + return .none + case .repositoryPathChanged(let repositoryID, let path): state.repositories[id: repositoryID]?.path = path state.clearValidation() @@ -410,6 +563,68 @@ struct WorkspaceCreationPromptFeature { state.clearValidation() return .none + case .existingRepositoryNameChanged(let entryID, let name): + state.existingRepositories[id: entryID]?.name = name + state.clearValidation() + return .none + + case .existingRepositoryRoleChanged(let entryID, let role): + state.existingRepositories[id: entryID]?.role = role + state.clearValidation() + return .none + + case .existingRepositoryMarkedForRemoval(let entryID): + guard state.existingRepositories[id: entryID] != nil else { + return .none + } + state.existingRepositories[id: entryID]?.removal = .init() + state.clearValidation() + return .none + + case .existingRepositoryRemovalUndone(let entryID): + state.existingRepositories[id: entryID]?.removal = nil + state.clearValidation() + return .none + + case .existingRepositoryDeleteFilesChanged(let entryID, let deleteFiles): + guard var removal = state.existingRepositories[id: entryID]?.removal else { + return .none + } + removal.deleteFiles = deleteFiles + if !deleteFiles { + removal.deleteBranch = false + } + state.existingRepositories[id: entryID]?.removal = removal + return .none + + case .existingRepositoryDeleteBranchChanged(let entryID, let deleteBranch): + guard let repository = state.existingRepositories[id: entryID], + var removal = repository.removal, + removal.deleteFiles || !deleteBranch, + repository.offersBranchDeletion || !deleteBranch + else { + return .none + } + removal.deleteBranch = deleteBranch + state.existingRepositories[id: entryID]?.removal = removal + return .none + + case .existingRepositoryMovedUp(let entryID): + guard let index = state.existingRepositories.index(id: entryID), index > 0 else { + return .none + } + state.existingRepositories.swapAt(index, index - 1) + return .none + + case .existingRepositoryMovedDown(let entryID): + guard let index = state.existingRepositories.index(id: entryID), + index < state.existingRepositories.count - 1 + else { + return .none + } + state.existingRepositories.swapAt(index, index + 1) + return .none + case .rootPathChosen(let path): state.rootPath = path state.isRootPathDirty = true @@ -419,7 +634,7 @@ struct WorkspaceCreationPromptFeature { case .cancelButtonTapped: return .send(.delegate(.cancel)) - case .createButtonTapped: + case .submitButtonTapped: let title = state.title.trimmingCharacters(in: .whitespacesAndNewlines) guard !title.isEmpty else { state.setValidation( @@ -436,7 +651,7 @@ struct WorkspaceCreationPromptFeature { ) return .none } - guard state.repositories.count >= 2 else { + guard state.remainingRepositoryCount >= 1 else { state.setValidation( ProjectWorkspaceCreationError.notEnoughRepositories.localizedDescription, target: nil @@ -457,17 +672,59 @@ struct WorkspaceCreationPromptFeature { } } state.clearValidation() - return .send( - .delegate( - .submit( - ProjectWorkspaceCreationDraft( - title: title, - rootURL: URL(filePath: rootPath, directoryHint: .isDirectory), - repositories: plans + let description = state.description.trimmingCharacters(in: .whitespacesAndNewlines) + let taskLinks = state.taskLinks.map { $0.value.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + switch state.mode { + case .create: + return .send( + .delegate( + .submit( + .create( + ProjectWorkspaceCreationDraft( + title: title, + description: description, + taskLinks: taskLinks, + rootURL: URL(filePath: rootPath, directoryHint: .isDirectory), + repositories: plans + ) + ) ) ) ) - ) + case .edit: + let members: [ProjectWorkspaceUpdateMember] = + state.existingRepositories + .filter { !$0.isMarkedForRemoval } + .map { .existing($0.editedEntry) } + + plans.map { .added($0) } + let removals = state.existingRepositories.compactMap { repository in + repository.removal.map { removal in + ProjectWorkspaceRepositoryRemoval( + entry: repository.entry, + deleteFiles: removal.deleteFiles, + deleteBranch: removal.deleteFiles && removal.deleteBranch + ) + } + } + return .send( + .delegate( + .submit( + .update( + ProjectWorkspaceUpdateRequest( + rootURL: URL(filePath: rootPath, directoryHint: .isDirectory), + title: title, + description: description, + taskLinks: taskLinks, + members: members, + removals: removals, + updatedAt: now + ) + ) + ) + ) + ) + } case .delegate: return .none @@ -521,7 +778,8 @@ struct WorkspaceCreationPromptFeature { case .missingPath: return .rootPath case .notEnoughRepositories, .linkCheckoutUnsupported, .destinationIsFile, - .workspaceAlreadyExists, .repositoryDoesNotExist, .linkAlreadyExists, .gitCommandFailed: + .workspaceAlreadyExists, .workspaceMetadataMissing, .repositoryDoesNotExist, + .linkAlreadyExists, .gitCommandFailed: return nil } } @@ -580,10 +838,12 @@ struct WorkspaceCreationPromptFeature { } } } + let role = repository.role?.trimmingCharacters(in: .whitespacesAndNewlines) return .success( ProjectWorkspaceRepositoryPlan( id: repository.id, name: repository.name, + role: role?.isEmpty == false ? role : nil, path: repository.path, sourceKind: repository.sourceKind, sourceLocation: sourceLocation, diff --git a/supacode/Features/Repositories/Views/WorkspaceEditorView.swift b/supacode/Features/Repositories/Views/WorkspaceEditorView.swift index df7faa95..e882dc5e 100644 --- a/supacode/Features/Repositories/Views/WorkspaceEditorView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceEditorView.swift @@ -2,192 +2,537 @@ import AppKit import ComposableArchitecture import SwiftUI -struct WorkspaceCreationPromptView: View { - @Bindable var store: StoreOf +/// Creates a workspace or edits an existing one. Both modes share the same +/// form; edit mode adds the already-materialized members as read-only +/// provenance rows with name/role editing, reordering, and staged removal. +struct WorkspaceEditorView: View { + @Bindable var store: StoreOf @FocusState private var isTitleFieldFocused: Bool var body: some View { VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 4) { - Text("New Workspace") - .font(.title3) - Text(repositoryCountText) - .foregroundStyle(.secondary) + headerSection + ScrollView { + VStack(alignment: .leading, spacing: 16) { + titleSection + if !store.mode.isEditing { + folderSection + } + descriptionSection + taskLinksSection + repositoriesSection + } + .padding(.trailing, 4) } + .frame(maxHeight: 560) + + if let message = store.validationMessage, !message.isEmpty { + Text(message) + .font(.footnote) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + + footer + } + .padding(20) + .frame(minWidth: 720) + .task { + isTitleFieldFocused = true + } + .interactiveDismissDisabled(store.isSaving) + .sheet( + isPresented: Binding( + get: { store.remoteRepositoryPrompt != nil }, + set: { isPresented in + if !isPresented { + store.send(.remoteRepositoryPromptDismissed) + } + } + ) + ) { + remoteRepositoryPromptView() + } + } + + // MARK: - Sections - VStack(alignment: .leading, spacing: 8) { - Text("Title") + private var headerSection: some View { + VStack(alignment: .leading, spacing: 4) { + Text(store.mode.isEditing ? "Edit Workspace" : "New Workspace") + .font(.title3) + if store.mode.isEditing { + Text(store.rootPath) + .font(.footnote.monospaced()) .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + Text(repositoryCountText) + .foregroundStyle(.secondary) + } + } + + private var titleSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Title") + .foregroundStyle(.secondary) + TextField( + "Workspace title", + text: Binding( + get: { store.title }, + set: { store.send(.titleChanged($0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .focused($isTitleFieldFocused) + .disabled(store.isSaving) + .overlay { + invalidFieldBorder(store.validationTarget == .title) + } + .onSubmit { + store.send(.submitButtonTapped) + } + helpText( + store.mode.isEditing + ? "Display name shown in the sidebar and workspace metadata. The folder on disk keeps its name." + : "A short name for the shared task folder shown in the sidebar and workspace metadata.") + } + } + + private var folderSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Folder") + .foregroundStyle(.secondary) + HStack(spacing: 8) { TextField( - "Workspace title", + "Workspace folder", text: Binding( - get: { store.title }, - set: { store.send(.titleChanged($0)) } + get: { store.rootPath }, + set: { store.send(.rootPathChanged($0)) } ) ) .textFieldStyle(.roundedBorder) - .focused($isTitleFieldFocused) - .disabled(store.isCreating) + .font(.body.monospaced()) + .disabled(store.isSaving) .overlay { - invalidFieldBorder(store.validationTarget == .title) + invalidFieldBorder(store.validationTarget == .rootPath) } - .onSubmit { - store.send(.createButtonTapped) + Button { + chooseFolder() + } label: { + Label("Choose Folder", systemImage: "folder") } - helpText( - "A short name for the shared task folder shown in the sidebar and workspace metadata.") + .help("Choose Workspace Folder") + .disabled(store.isSaving) } + Text(store.rootPathPreview) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + helpText( + "Where Prowl creates the workspace root. Until you edit it, this path follows the workspace title." + ) + } + } + + private var descriptionSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Description") + .foregroundStyle(.secondary) + TextField( + "What this workspace is for", + text: Binding( + get: { store.description }, + set: { store.send(.descriptionChanged($0)) } + ), + axis: .vertical + ) + .lineLimit(2...5) + .textFieldStyle(.roundedBorder) + .disabled(store.isSaving) + helpText("Optional task summary shown in the workspace detail view and available to agents.") + } + } - VStack(alignment: .leading, spacing: 8) { - Text("Folder") + private var taskLinksSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Task Links") .foregroundStyle(.secondary) + Spacer() + Button { + store.send(.addTaskLinkButtonTapped) + } label: { + Label("Add Link", systemImage: "plus") + } + .controlSize(.small) + .help("Add a task link or identifier") + .disabled(store.isSaving) + } + ForEach(store.taskLinks) { link in HStack(spacing: 8) { TextField( - "Workspace folder", + "https://… or an issue key", text: Binding( - get: { store.rootPath }, - set: { store.send(.rootPathChanged($0)) } + get: { link.value }, + set: { store.send(.taskLinkChanged(link.id, $0)) } ) ) .textFieldStyle(.roundedBorder) .font(.body.monospaced()) - .disabled(store.isCreating) - .overlay { - invalidFieldBorder(store.validationTarget == .rootPath) - } + .disabled(store.isSaving) Button { - chooseFolder() + store.send(.removeTaskLink(link.id)) } label: { - Label("Choose Folder", systemImage: "folder") + Image(systemName: "minus.circle") + .accessibilityLabel("Remove Link") } - .help("Choose Workspace Folder") - .disabled(store.isCreating) + .buttonStyle(.borderless) + .help("Remove Link") + .disabled(store.isSaving) } - Text(store.rootPathPreview) - .font(.footnote.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.enabled) - helpText( - "Where Prowl creates the workspace root. Until you edit it, this path follows the workspace title." - ) } + helpText("Optional issue URLs or identifiers for the work item. Empty rows are dropped on save.") + } + } - VStack(alignment: .leading, spacing: 8) { - Text("Repositories") - .foregroundStyle(.secondary) - HStack(spacing: 8) { - Menu { - ForEach(store.availableOpenedRepositories) { repository in - Button { - store.send(.addOpenedRepository(repository.id)) - } label: { - Text(repository.name.isEmpty ? repository.sourceLocation : repository.name) - } + private var repositoriesSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Repositories") + .foregroundStyle(.secondary) + HStack(spacing: 8) { + Menu { + ForEach(store.availableOpenedRepositories) { repository in + Button { + store.send(.addOpenedRepository(repository.id)) + } label: { + Text(repository.name.isEmpty ? repository.sourceLocation : repository.name) } - } label: { - Label("Add Opened", systemImage: "folder.badge.plus") } - .help("Add Opened Path") - .disabled(store.isCreating || store.availableOpenedRepositories.isEmpty) + } label: { + Label("Add Opened", systemImage: "folder.badge.plus") + } + .help("Add Opened Path") + .disabled(store.isSaving || store.availableOpenedRepositories.isEmpty) - Button { - store.send(.addRemoteButtonTapped) - } label: { - Label("Add Remote", systemImage: "network") - } - .help("Add Remote Repository") - .disabled(store.isCreating) + Button { + store.send(.addRemoteButtonTapped) + } label: { + Label("Add Remote", systemImage: "network") + } + .help("Add Remote Repository") + .disabled(store.isSaving) - Button { - chooseRepositorySource(kind: .localRepository) - } label: { - Label("Add Local", systemImage: "folder") - } - .help("Add Local Repository") - .disabled(store.isCreating) + Button { + chooseRepositorySource(kind: .localRepository) + } label: { + Label("Add Local", systemImage: "folder") } - helpText( - "Add at least two repositories. Opened and local repositories can be linked or materialized as worktrees." - ) - ScrollViewReader { proxy in - ScrollView { - VStack(spacing: 0) { - ForEach(store.repositories) { repository in - repositoryEditor(repository) - .id(repository.id) - if repository.id != store.repositories.last?.id { - Divider() - } - } - } + .help("Add Local Repository") + .disabled(store.isSaving) + } + helpText(repositoriesHelpText) + ScrollViewReader { proxy in + VStack(spacing: 0) { + ForEach(store.existingRepositories) { repository in + existingRepositoryEditor(repository) + .id(repository.id) + Divider() } - .onChange(of: store.validationRequestID) { _, _ in - guard let repositoryID = validationRepositoryID else { - return - } - withAnimation(.easeInOut(duration: 0.2)) { - proxy.scrollTo(repositoryID, anchor: .center) + ForEach(store.repositories) { repository in + repositoryEditor(repository) + .id(repository.id) + if repository.id != store.repositories.last?.id { + Divider() } } + if store.existingRepositories.isEmpty, store.repositories.isEmpty { + Text("No repositories yet. Add one from the buttons above.") + .font(.callout) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 24) + } } - .frame(maxHeight: 340) - .clipShape(.rect(cornerRadius: 8)) - .overlay { - RoundedRectangle(cornerRadius: 8) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + .onChange(of: store.validationRequestID) { _, _ in + guard let repositoryID = validationRepositoryID else { + return + } + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(repositoryID, anchor: .center) + } } } + .clipShape(.rect(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + } + } - if let message = store.validationMessage, !message.isEmpty { - Text(message) + private var repositoriesHelpText: LocalizedStringKey { + if store.mode.isEditing { + return """ + Added repositories are materialized when you save. \ + Existing members keep their source and checkout; remove and re-add one to change them. + """ + } + return "Add at least one repository. Opened and local repositories can be linked or materialized as worktrees." + } + + private var footer: some View { + HStack { + if store.isSaving { + ProgressView() + .controlSize(.small) + Text(store.mode.isEditing ? "Saving…" : "Creating…") .font(.footnote) - .foregroundStyle(.red) + .foregroundStyle(.secondary) + } + Spacer() + Button("Cancel") { + store.send(.cancelButtonTapped) + } + .keyboardShortcut(.cancelAction) + .help(cancelHelp) + .disabled(store.isSaving && store.mode.isEditing) + Button(store.mode.isEditing ? "Save" : "Create") { + store.send(.submitButtonTapped) } + .keyboardShortcut(.defaultAction) + .help(store.mode.isEditing ? "Save Workspace (↩)" : "Create Workspace (↩)") + .disabled(store.isSaving) + } + } - HStack { - if store.isCreating { - ProgressView() - .controlSize(.small) - } - Spacer() - Button("Cancel") { - store.send(.cancelButtonTapped) + private var cancelHelp: String { + if store.isSaving { + return store.mode.isEditing + ? String(localized: "Saving cannot be canceled") + : String(localized: "Cancel creation and roll back (Esc)") + } + return String(localized: "Cancel (Esc)") + } + + private var repositoryCountText: String { + let count = store.remainingRepositoryCount + var text = count == 1 ? String(localized: "1 repository") : String(localized: "\(count) repositories") + if store.hasPendingRemovals { + let removed = store.existingRepositories.filter(\.isMarkedForRemoval).count + text += + removed == 1 + ? String(localized: ", 1 to remove") : String(localized: ", \(removed) to remove") + } + return text + } + + // MARK: - Existing member rows + + private func existingRepositoryEditor(_ repository: WorkspaceEditorExistingRepository) -> some View { + VStack(alignment: .leading, spacing: 10) { + existingRepositoryHeader(repository) + if let removal = repository.removal { + existingRepositoryRemovalOptions(repository, removal: removal) + } else { + existingRepositoryFields(repository) + existingRepositoryProvenance(repository) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + .background { + if repository.isMarkedForRemoval { + Color.red.opacity(0.06) + } + } + } + + private func existingRepositoryHeader(_ repository: WorkspaceEditorExistingRepository) -> some View { + HStack(spacing: 10) { + Text(repository.name.isEmpty ? repository.entry.name : repository.name) + .fontWeight(.medium) + .lineLimit(1) + .strikethrough(repository.isMarkedForRemoval) + sourceKindBadge(repository.entry.sourceKind) + if repository.isMarkedForRemoval { + Text("Removed on save") + .font(.caption) + .foregroundStyle(.red) + } + Spacer() + Button { + store.send(.existingRepositoryMovedUp(repository.id)) + } label: { + Image(systemName: "chevron.up") + .accessibilityLabel("Move Up") + } + .buttonStyle(.borderless) + .help("Move Up") + .disabled(store.isSaving || store.existingRepositories.first?.id == repository.id) + Button { + store.send(.existingRepositoryMovedDown(repository.id)) + } label: { + Image(systemName: "chevron.down") + .accessibilityLabel("Move Down") + } + .buttonStyle(.borderless) + .help("Move Down") + .disabled(store.isSaving || store.existingRepositories.last?.id == repository.id) + if repository.isMarkedForRemoval { + Button("Undo") { + store.send(.existingRepositoryRemovalUndone(repository.id)) } - .keyboardShortcut(.cancelAction) - .help(store.isCreating ? "Cancel creation and roll back (Esc)" : "Cancel (Esc)") - Button("Create") { - store.send(.createButtonTapped) + .controlSize(.small) + .help("Keep this repository in the workspace") + .disabled(store.isSaving) + } else { + Button { + store.send(.existingRepositoryMarkedForRemoval(repository.id)) + } label: { + Image(systemName: "trash") + .accessibilityLabel("Remove from Workspace") } - .keyboardShortcut(.defaultAction) - .help("Create Workspace (↩)") - .disabled(store.isCreating) + .buttonStyle(.borderless) + .help("Remove from Workspace on save") + .disabled(store.isSaving) } } - .padding(20) - .frame(minWidth: 680) - .task { - isTitleFieldFocused = true + } + + private func existingRepositoryFields(_ repository: WorkspaceEditorExistingRepository) -> some View { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + Text("Name") + .foregroundStyle(.secondary) + TextField( + "Repository name", + text: Binding( + get: { repository.name }, + set: { store.send(.existingRepositoryNameChanged(repository.id, $0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .disabled(store.isSaving) + } + VStack(alignment: .leading, spacing: 4) { + Text("Role") + .foregroundStyle(.secondary) + TextField( + "app, backend, docs…", + text: Binding( + get: { repository.role }, + set: { store.send(.existingRepositoryRoleChanged(repository.id, $0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .disabled(store.isSaving) + } } - .sheet( - isPresented: Binding( - get: { store.remoteRepositoryPrompt != nil }, - set: { isPresented in - if !isPresented { - store.send(.remoteRepositoryPromptDismissed) + } + + private func existingRepositoryProvenance(_ repository: WorkspaceEditorExistingRepository) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(provenanceTitle(repository.entry)) + .font(.footnote) + .foregroundStyle(.secondary) + if let sourceLocation = repository.entry.sourceLocation, !sourceLocation.isEmpty { + Text(sourceLocation) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + } + + private func existingRepositoryRemovalOptions( + _ repository: WorkspaceEditorExistingRepository, + removal: WorkspaceEditorExistingRepository.Removal + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + Toggle( + "Also delete the folder inside the workspace", + isOn: Binding( + get: { removal.deleteFiles }, + set: { store.send(.existingRepositoryDeleteFilesChanged(repository.id, $0)) } + ) + ) + .help(removalDeleteFilesHelp(repository.entry)) + .disabled(store.isSaving) + if repository.offersBranchDeletion, let branchName = repository.entry.branchName { + Toggle( + isOn: Binding( + get: { removal.deleteBranch }, + set: { store.send(.existingRepositoryDeleteBranchChanged(repository.id, $0)) } + ) + ) { + HStack(spacing: 4) { + Text("Delete branch") + Text(branchName) + .font(.body.monospaced()) + Text("in the source repository") } } - ) - ) { - remoteRepositoryPromptView() + .padding(.leading, 20) + .help("Delete the branch with git branch -D after the worktree is removed. Protected branches are kept.") + .disabled(store.isSaving || !removal.deleteFiles) + } + helpText(removalHelp(repository.entry)) } } - private var repositoryCountText: String { - store.repositories.count == 1 ? "1 repository" : "\(store.repositories.count) repositories" + private func provenanceTitle(_ entry: ProjectWorkspaceRepositoryEntry) -> String { + let location = entry.path + switch entry.sourceKind { + case .remote: + if let baseRef = entry.branchName ?? entry.baseRef { + return String(localized: "Clone of \(baseRef) at \(location)") + } + return String(localized: "Clone at \(location)") + case .existingPath, .localRepository: + if let branchName = entry.branchName { + return String(localized: "Worktree on \(branchName) at \(location)") + } + if let baseRef = entry.baseRef { + return String(localized: "Worktree on \(baseRef) at \(location)") + } + return String(localized: "Linked checkout at \(location)") + case .bareRepository: + let ref = entry.branchName ?? entry.baseRef ?? "HEAD" + return String(localized: "Worktree on \(ref) at \(location)") + } + } + + private func removalDeleteFilesHelp(_ entry: ProjectWorkspaceRepositoryEntry) -> String { + switch entry.sourceKind { + case .remote: + return String(localized: "Deletes the cloned folder, including any uncommitted work in it.") + case .existingPath, .localRepository: + if entry.branchName == nil, entry.baseRef == nil { + return String(localized: "Removes only the symlink; the linked repository is untouched.") + } + return String( + localized: + "Unregisters the worktree from its source repository (git worktree remove --force) and deletes the folder.") + case .bareRepository: + return String( + localized: + "Unregisters the worktree from its source repository (git worktree remove --force) and deletes the folder.") + } + } + + private func removalHelp(_ entry: ProjectWorkspaceRepositoryEntry) -> LocalizedStringKey { + "The entry leaves the workspace metadata when you save. Files stay on disk unless you delete them here." } + // MARK: - New member rows + private func repositoryEditor(_ repository: ProjectWorkspaceCreationRepository) -> some View { VStack(alignment: .leading, spacing: 10) { repositoryHeader(repository) @@ -207,6 +552,12 @@ struct WorkspaceCreationPromptView: View { sourceKindBadge(repository.sourceKind) + if store.mode.isEditing { + Text("Added on save") + .font(.caption) + .foregroundStyle(.tint) + } + Spacer() Button { @@ -217,7 +568,7 @@ struct WorkspaceCreationPromptView: View { } .buttonStyle(.borderless) .help("Remove Repository") - .disabled(store.isCreating) + .disabled(store.isSaving) } } @@ -236,13 +587,28 @@ struct WorkspaceCreationPromptView: View { ) ) .textFieldStyle(.roundedBorder) - .disabled(store.isCreating) + .disabled(store.isSaving) .overlay { invalidFieldBorder(repositoryFieldIsInvalid(repository, .name)) } helpText("Display name for this repository in the workspace metadata.") } + VStack(alignment: .leading, spacing: 4) { + Text("Role") + .foregroundStyle(.secondary) + TextField( + "app, backend, docs…", + text: Binding( + get: { repository.role ?? "" }, + set: { store.send(.repositoryRoleChanged(repository.id, $0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .disabled(store.isSaving) + helpText("Optional short role that tells agents what this repository is for.") + } + VStack(alignment: .leading, spacing: 4) { Text("Folder inside workspace") .foregroundStyle(.secondary) @@ -254,7 +620,7 @@ struct WorkspaceCreationPromptView: View { ) ) .textFieldStyle(.roundedBorder) - .disabled(store.isCreating) + .disabled(store.isSaving) helpText( "Destination folder under the workspace root. It does not change the original source path." ) @@ -274,7 +640,7 @@ struct WorkspaceCreationPromptView: View { ) .textFieldStyle(.roundedBorder) .font(.body.monospaced()) - .disabled(store.isCreating) + .disabled(store.isSaving) .overlay { invalidFieldBorder(repositoryFieldIsInvalid(repository, .source)) } @@ -287,7 +653,7 @@ struct WorkspaceCreationPromptView: View { .accessibilityLabel("Choose Repository Source") } .help("Choose Repository Source") - .disabled(store.isCreating) + .disabled(store.isSaving) } } helpText(sourceLocationHelpText(repository.sourceKind)) @@ -314,7 +680,7 @@ struct WorkspaceCreationPromptView: View { .labelsHidden() .frame(width: 150) .help("Choose Branch Action") - .disabled(store.isCreating) + .disabled(store.isSaving) if repository.checkoutMode == .createBranch { TextField( @@ -325,7 +691,7 @@ struct WorkspaceCreationPromptView: View { ) ) .textFieldStyle(.roundedBorder) - .disabled(store.isCreating) + .disabled(store.isSaving) .overlay { invalidFieldBorder(repositoryFieldIsInvalid(repository, .branchName)) } @@ -338,12 +704,12 @@ struct WorkspaceCreationPromptView: View { : String(localized: "Existing branch"), selection: repository.baseRef, options: repository.baseRefOptions, - isDisabled: store.isCreating || repository.baseRefOptions.isEmpty, + isDisabled: store.isSaving || repository.baseRefOptions.isEmpty, isInvalid: repositoryFieldIsInvalid(repository, .baseRef) ) { ref in store.send(.repositoryBaseRefChanged(repository.id, ref)) } - .disabled(store.isCreating || repository.baseRefOptions.isEmpty) + .disabled(store.isSaving || repository.baseRefOptions.isEmpty) } } @@ -368,7 +734,7 @@ struct WorkspaceCreationPromptView: View { } .pickerStyle(.radioGroup) .labelsHidden() - .disabled(store.isCreating) + .disabled(store.isSaving) } } } @@ -459,6 +825,8 @@ struct WorkspaceCreationPromptView: View { } } + // MARK: - Panels + private func chooseFolder() { let panel = NSOpenPanel() panel.canChooseFiles = false @@ -516,6 +884,8 @@ struct WorkspaceCreationPromptView: View { return panel } + // MARK: - Copy helpers + private func sourceKindTitle(_ kind: ProjectWorkspaceRepositorySourceKind) -> String { switch kind { case .existingPath: @@ -621,16 +991,20 @@ struct WorkspaceCreationPromptView: View { .fixedSize(horizontal: false, vertical: true) } - private var validationRepositoryID: Repository.ID? { - guard case .repository(let repositoryID, _) = store.validationTarget else { + private var validationRepositoryID: String? { + switch store.validationTarget { + case .repository(let repositoryID, _): + return repositoryID + case .existingRepository(let entryID): + return entryID + case .title, .rootPath, nil: return nil } - return repositoryID } private func repositoryFieldIsInvalid( _ repository: ProjectWorkspaceCreationRepository, - _ field: WorkspaceCreationPromptFeature.RepositoryField + _ field: WorkspaceEditorFeature.RepositoryField ) -> Bool { store.validationTarget == .repository(repository.id, field) } diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 9e780e7f..5fa21112 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -380,7 +380,7 @@ struct AppFeatureCommandPaletteTests { let resolvedRootPath = expectedDefaultWorkspaceRootPath(for: title) await store.send(.commandPalette(.delegate(.newWorkspace))) await store.receive(\.repositories.workspaceCreation.promptRequested) { - $0.repositories.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( + $0.repositories.workspaceEditor = WorkspaceEditorFeature.State( repositories: [], title: title, rootPath: requestedRootPath @@ -390,7 +390,7 @@ struct AppFeatureCommandPaletteTests { await store.receive(\.repositories.workspaceCreation.defaultRootPathResolved) } else { await store.receive(\.repositories.workspaceCreation.defaultRootPathResolved) { - $0.repositories.workspaceCreationPrompt?.rootPath = resolvedRootPath + $0.repositories.workspaceEditor?.rootPath = resolvedRootPath } } } diff --git a/supacodeTests/ProjectWorkspaceTests.swift b/supacodeTests/ProjectWorkspaceTests.swift index 928a77a4..f3f09eb6 100644 --- a/supacodeTests/ProjectWorkspaceTests.swift +++ b/supacodeTests/ProjectWorkspaceTests.swift @@ -439,7 +439,7 @@ struct ProjectWorkspaceTests { ) #expect( - WorkspaceCreationPromptFeature.plan(for: repository).map(\.checkout) + WorkspaceEditorFeature.plan(for: repository).map(\.checkout) == .success(.trackRemoteRef(remoteRef: "origin/chore/x", branchName: "chore/x")) ) } @@ -462,7 +462,7 @@ struct ProjectWorkspaceTests { // out the local branch directly instead of resetting it to the remote. #expect(repository.resettableLocalBranchName == "chore/x") #expect( - WorkspaceCreationPromptFeature.plan(for: repository).map(\.checkout) + WorkspaceEditorFeature.plan(for: repository).map(\.checkout) == .success(.useExistingRef("chore/x")) ) } @@ -492,19 +492,19 @@ struct ProjectWorkspaceTests { repository.resetLocalBranchToRemote = true #expect( - WorkspaceCreationPromptFeature.plan(for: repository).map(\.checkout) + WorkspaceEditorFeature.plan(for: repository).map(\.checkout) == .success(.trackRemoteRef(remoteRef: "origin/chore/x", branchName: "chore/x")) ) } @Test func defaultRepositoryNameStripsGitSuffix() { #expect( - WorkspaceCreationPromptFeature.defaultRepositoryName( + WorkspaceEditorFeature.defaultRepositoryName( for: URL(fileURLWithPath: "/tmp/maker.git")) == "maker" ) #expect( - WorkspaceCreationPromptFeature.defaultRepositoryName(for: URL(fileURLWithPath: "/tmp/maker")) + WorkspaceEditorFeature.defaultRepositoryName(for: URL(fileURLWithPath: "/tmp/maker")) == "maker" ) } @@ -725,7 +725,7 @@ struct ProjectWorkspaceTests { ) #expect( - WorkspaceCreationPromptFeature.plan(for: repository) + WorkspaceEditorFeature.plan(for: repository) == .failure(.missingBranchName("App")) ) } @@ -736,7 +736,7 @@ struct ProjectWorkspaceTests { name: "App", rootURL: URL(fileURLWithPath: "/tmp/app") ) - #expect(WorkspaceCreationPromptFeature.plan(for: linked).map(\.checkout) == .success(.link)) + #expect(WorkspaceEditorFeature.plan(for: linked).map(\.checkout) == .success(.link)) var existing = ProjectWorkspaceCreationRepository( id: "api", @@ -746,12 +746,12 @@ struct ProjectWorkspaceTests { checkoutMode: .useExistingRef ) #expect( - WorkspaceCreationPromptFeature.plan(for: existing) + WorkspaceEditorFeature.plan(for: existing) == .failure(.missingExistingRef("API")) ) existing.baseRef = "main" #expect( - WorkspaceCreationPromptFeature.plan(for: existing).map(\.checkout) + WorkspaceEditorFeature.plan(for: existing).map(\.checkout) == .success(.useExistingRef("main")) ) } diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 43ad4c91..9a25502e 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -740,7 +740,7 @@ struct RepositoriesFeatureTests { await store.finish() } - @Test func workspaceCreationPromptOffersOpenedRepositoriesWithoutAddingThem() async { + @Test func workspaceEditorOffersOpenedRepositoriesWithoutAddingThem() async { let repository = makeRepository(id: "/tmp/repo-a", name: "Repo A", worktrees: []) let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() @@ -750,7 +750,7 @@ struct RepositoriesFeatureTests { let requestedRootPath = defaultWorkspaceBaseRootPath(for: title) let resolvedRootPath = expectedDefaultWorkspaceRootPath(for: title) await store.send(.workspaceCreation(.promptRequested)) { - $0.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( + $0.workspaceEditor = WorkspaceEditorFeature.State( repositories: [], title: title, rootPath: requestedRootPath, @@ -767,12 +767,12 @@ struct RepositoriesFeatureTests { await store.receive(\.workspaceCreation.defaultRootPathResolved) } else { await store.receive(\.workspaceCreation.defaultRootPathResolved) { - $0.workspaceCreationPrompt?.rootPath = resolvedRootPath + $0.workspaceEditor?.rootPath = resolvedRootPath } } } - @Test func workspaceCreationPromptAddsOpenedRepositoryOnRequest() async { + @Test func workspaceEditorAddsOpenedRepositoryOnRequest() async { let repoRootA = "/tmp/repo-a" let candidate = ProjectWorkspaceCreationRepository( id: repoRootA, @@ -780,14 +780,14 @@ struct RepositoriesFeatureTests { rootURL: URL(fileURLWithPath: repoRootA) ) let store = TestStore( - initialState: WorkspaceCreationPromptFeature.State( + initialState: WorkspaceEditorFeature.State( repositories: [], title: "Workspace", rootPath: "/tmp/workspace", openedRepositoryCandidates: [candidate] ) ) { - WorkspaceCreationPromptFeature() + WorkspaceEditorFeature() } await store.send(.addOpenedRepository(repoRootA)) { @@ -811,13 +811,13 @@ struct RepositoriesFeatureTests { ] ) let store = TestStore( - initialState: WorkspaceCreationPromptFeature.State( + initialState: WorkspaceEditorFeature.State( repositories: [repo], title: "Workspace", rootPath: "/tmp/workspace" ) ) { - WorkspaceCreationPromptFeature() + WorkspaceEditorFeature() } await store.send(.repositoryResetLocalBranchChanged(repoID, true)) { @@ -830,7 +830,7 @@ struct RepositoriesFeatureTests { } } - @Test func workspaceCreationPromptIgnoresDuplicateOpenedRepository() async { + @Test func workspaceEditorIgnoresDuplicateOpenedRepository() async { let repoRootA = "/tmp/repo-a" let candidate = ProjectWorkspaceCreationRepository( id: repoRootA, @@ -838,20 +838,20 @@ struct RepositoriesFeatureTests { rootURL: URL(fileURLWithPath: repoRootA) ) let store = TestStore( - initialState: WorkspaceCreationPromptFeature.State( + initialState: WorkspaceEditorFeature.State( repositories: [candidate], title: "Workspace", rootPath: "/tmp/workspace", openedRepositoryCandidates: [candidate] ) ) { - WorkspaceCreationPromptFeature() + WorkspaceEditorFeature() } await store.send(.addOpenedRepository(repoRootA)) } - @Test func workspaceCreationPromptOffersLoadedRepositories() async { + @Test func workspaceEditorOffersLoadedRepositories() async { let testID = UUID().uuidString let repoRootA = "/tmp/\(testID)-repo-a" let repoRootB = "/tmp/\(testID)-repo-b" @@ -868,7 +868,7 @@ struct RepositoriesFeatureTests { let requestedRootPath = defaultWorkspaceBaseRootPath(for: title) let resolvedRootPath = expectedDefaultWorkspaceRootPath(for: title) await store.send(.workspaceCreation(.promptRequested)) { - $0.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( + $0.workspaceEditor = WorkspaceEditorFeature.State( repositories: [], title: title, rootPath: requestedRootPath, @@ -890,12 +890,12 @@ struct RepositoriesFeatureTests { await store.receive(\.workspaceCreation.defaultRootPathResolved) } else { await store.receive(\.workspaceCreation.defaultRootPathResolved) { - $0.workspaceCreationPrompt?.rootPath = resolvedRootPath + $0.workspaceEditor?.rootPath = resolvedRootPath } } } - @Test func workspaceCreationPromptLoadsBaseRefsForAddedOpenedRepository() async { + @Test func workspaceEditorLoadsBaseRefsForAddedOpenedRepository() async { let testID = UUID().uuidString let repoRootA = "/tmp/\(testID)-repo-a" let repoRootB = "/tmp/\(testID)-repo-b" @@ -928,7 +928,7 @@ struct RepositoriesFeatureTests { let requestedRootPath = defaultWorkspaceBaseRootPath(for: title) let resolvedRootPath = expectedDefaultWorkspaceRootPath(for: title) await store.send(.workspaceCreation(.promptRequested)) { - $0.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( + $0.workspaceEditor = WorkspaceEditorFeature.State( repositories: [], title: title, rootPath: requestedRootPath, @@ -950,11 +950,11 @@ struct RepositoriesFeatureTests { await store.receive(\.workspaceCreation.defaultRootPathResolved) } else { await store.receive(\.workspaceCreation.defaultRootPathResolved) { - $0.workspaceCreationPrompt?.rootPath = resolvedRootPath + $0.workspaceEditor?.rootPath = resolvedRootPath } } - await store.send(.workspaceCreationPrompt(.presented(.addOpenedRepository(repoRootA)))) { - $0.workspaceCreationPrompt?.repositories.append( + await store.send(.workspaceEditor(.presented(.addOpenedRepository(repoRootA)))) { + $0.workspaceEditor?.repositories.append( ProjectWorkspaceCreationRepository( id: repoRootA, name: "Repo A", @@ -962,18 +962,18 @@ struct RepositoriesFeatureTests { ) ) } - await store.receive(\.workspaceCreationPrompt.presented.delegate.baseRefSourceChanged) + await store.receive(\.workspaceEditor.presented.delegate.baseRefSourceChanged) await store.receive(\.workspaceCreation.refreshBaseRefs) await store.receive(\.workspaceCreation.baseRefsLoaded) { - $0.workspaceCreationPrompt?.repositories[id: repoRootA]?.baseRef = "main" - $0.workspaceCreationPrompt?.repositories[id: repoRootA]?.baseRefOptions = [ + $0.workspaceEditor?.repositories[id: repoRootA]?.baseRef = "main" + $0.workspaceEditor?.repositories[id: repoRootA]?.baseRefOptions = [ GitBranchRefOption(ref: "main", kind: .local), GitBranchRefOption(ref: "origin/main", kind: .remoteTracking), ] } } - @Test func workspaceCreationPromptSubmitBuildsDraft() async { + @Test func workspaceEditorSubmitBuildsDraft() async { let repoRootA = "/tmp/repo-a" let repoRootB = "/tmp/repo-b" let repositories = [ @@ -989,59 +989,61 @@ struct RepositoriesFeatureTests { ), ] let store = TestStore( - initialState: WorkspaceCreationPromptFeature.State( + initialState: WorkspaceEditorFeature.State( repositories: repositories, title: " Multi Repo ", rootPath: " /tmp/multi-repo-workspace " ) ) { - WorkspaceCreationPromptFeature() + WorkspaceEditorFeature() } - await store.send(.createButtonTapped) + await store.send(.submitButtonTapped) await store.receive( .delegate( .submit( - ProjectWorkspaceCreationDraft( - title: "Multi Repo", - rootURL: URL(filePath: "/tmp/multi-repo-workspace", directoryHint: .isDirectory), - repositories: [ - ProjectWorkspaceRepositoryPlan( - id: repoRootA, - name: "Repo A", - path: nil, - sourceKind: .existingPath, - sourceLocation: repoRootA, - checkout: .link - ), - ProjectWorkspaceRepositoryPlan( - id: repoRootB, - name: "Repo B", - path: nil, - sourceKind: .existingPath, - sourceLocation: repoRootB, - checkout: .link - ), - ] + .create( + ProjectWorkspaceCreationDraft( + title: "Multi Repo", + rootURL: URL(filePath: "/tmp/multi-repo-workspace", directoryHint: .isDirectory), + repositories: [ + ProjectWorkspaceRepositoryPlan( + id: repoRootA, + name: "Repo A", + path: nil, + sourceKind: .existingPath, + sourceLocation: repoRootA, + checkout: .link + ), + ProjectWorkspaceRepositoryPlan( + id: repoRootB, + name: "Repo B", + path: nil, + sourceKind: .existingPath, + sourceLocation: repoRootB, + checkout: .link + ), + ] + ) ) ) ) ) } - @Test func workspaceCreationPromptAddsRemoteRepositoryAfterLoadingRefs() async { + @Test func workspaceEditorAddsRemoteRepositoryAfterLoadingRefs() async { let options = [ GitBranchRefOption(ref: "origin/feature/login", kind: .fetchedRemote), GitBranchRefOption(ref: "origin/main", kind: .fetchedRemote), ] let store = TestStore( - initialState: WorkspaceCreationPromptFeature.State( + initialState: WorkspaceEditorFeature.State( repositories: [], title: "Workspace", rootPath: "/tmp/workspace" ) ) { - WorkspaceCreationPromptFeature() + WorkspaceEditorFeature() } withDependencies: { $0.uuid = .incrementing $0.gitClient.remoteBranchRefs = { remoteURL in @@ -1051,7 +1053,7 @@ struct RepositoriesFeatureTests { } await store.send(.addRemoteButtonTapped) { - $0.remoteRepositoryPrompt = WorkspaceCreationPromptFeature.RemoteRepositoryPromptState() + $0.remoteRepositoryPrompt = WorkspaceEditorFeature.RemoteRepositoryPromptState() } await store.send(.remoteRepositoryPromptURLChanged(" git@github.com:onevcat/app.git ")) { $0.remoteRepositoryPrompt?.url = " git@github.com:onevcat/app.git " @@ -1086,15 +1088,15 @@ struct RepositoriesFeatureTests { } } - @Test func workspaceCreationPromptFolderFollowsTitleUntilEdited() async { + @Test func workspaceEditorFolderFollowsTitleUntilEdited() async { let store = TestStore( - initialState: WorkspaceCreationPromptFeature.State( + initialState: WorkspaceEditorFeature.State( repositories: [], title: "Workspace", rootPath: "/tmp/workspace" ) ) { - WorkspaceCreationPromptFeature() + WorkspaceEditorFeature() } let requestedRootPath = defaultWorkspaceBaseRootPath(for: "Client App") @@ -1123,19 +1125,19 @@ struct RepositoriesFeatureTests { } } - @Test func workspaceCreationPromptRemoteLoadUsesDetectedDefaultFromOptions() async { + @Test func workspaceEditorRemoteLoadUsesDetectedDefaultFromOptions() async { let store = TestStore( - initialState: WorkspaceCreationPromptFeature.State( + initialState: WorkspaceEditorFeature.State( repositories: [], title: "Workspace", rootPath: "/tmp/workspace" ) ) { - WorkspaceCreationPromptFeature() + WorkspaceEditorFeature() } await store.send(.addRemoteButtonTapped) { - $0.remoteRepositoryPrompt = WorkspaceCreationPromptFeature.RemoteRepositoryPromptState() + $0.remoteRepositoryPrompt = WorkspaceEditorFeature.RemoteRepositoryPromptState() } await store.send( .remoteRepositoryPromptLoaded( @@ -1156,15 +1158,15 @@ struct RepositoriesFeatureTests { } } - @Test func workspaceCreationPromptAddsLocalRepository() async { + @Test func workspaceEditorAddsLocalRepository() async { let store = TestStore( - initialState: WorkspaceCreationPromptFeature.State( + initialState: WorkspaceEditorFeature.State( repositories: [], title: "Workspace", rootPath: "/tmp/workspace" ) ) { - WorkspaceCreationPromptFeature() + WorkspaceEditorFeature() } withDependencies: { $0.uuid = .incrementing } @@ -1182,10 +1184,10 @@ struct RepositoriesFeatureTests { await store.receive(.delegate(.baseRefSourceChanged(UUID(0).uuidString))) } - @Test func workspaceCreationPromptClearsAndReloadsBaseRefsWhenSourceLocationChanges() async { + @Test func workspaceEditorClearsAndReloadsBaseRefsWhenSourceLocationChanges() async { let repositoryID = "repo" let store = TestStore( - initialState: WorkspaceCreationPromptFeature.State( + initialState: WorkspaceEditorFeature.State( repositories: [ ProjectWorkspaceCreationRepository( id: repositoryID, @@ -1201,7 +1203,7 @@ struct RepositoriesFeatureTests { rootPath: "/tmp/workspace" ) ) { - WorkspaceCreationPromptFeature() + WorkspaceEditorFeature() } await store.send(.repositorySourceLocationChanged(repositoryID, "/tmp/repo-b.git")) { @@ -1212,10 +1214,10 @@ struct RepositoriesFeatureTests { await store.receive(.delegate(.baseRefSourceChanged(repositoryID))) } - @Test func workspaceCreationPromptClearsBaseRefsWhenRemoteSourceLocationChanges() async { + @Test func workspaceEditorClearsBaseRefsWhenRemoteSourceLocationChanges() async { let repositoryID = "remote" let store = TestStore( - initialState: WorkspaceCreationPromptFeature.State( + initialState: WorkspaceEditorFeature.State( repositories: [ ProjectWorkspaceCreationRepository( id: repositoryID, @@ -1231,7 +1233,7 @@ struct RepositoriesFeatureTests { rootPath: "/tmp/workspace" ) ) { - WorkspaceCreationPromptFeature() + WorkspaceEditorFeature() } await store.send( @@ -1243,11 +1245,11 @@ struct RepositoriesFeatureTests { } } - @Test func workspaceCreationPromptRejectsUnknownBaseRef() async { + @Test func workspaceEditorRejectsUnknownBaseRef() async { let repoRootA = "/tmp/repo-a" let repoRootB = "/tmp/repo-b" let store = TestStore( - initialState: WorkspaceCreationPromptFeature.State( + initialState: WorkspaceEditorFeature.State( repositories: [ ProjectWorkspaceCreationRepository( id: repoRootA, @@ -1266,7 +1268,7 @@ struct RepositoriesFeatureTests { rootPath: "/tmp/workspace" ) ) { - WorkspaceCreationPromptFeature() + WorkspaceEditorFeature() } await store.send(.repositoryBaseRefChanged(repoRootA, "missing-branch")) @@ -1275,11 +1277,11 @@ struct RepositoriesFeatureTests { } } - @Test func workspaceCreationPromptRequiresExistingRefForUseExistingMode() async { + @Test func workspaceEditorRequiresExistingRefForUseExistingMode() async { let repoRootA = "/tmp/repo-a" let repoRootB = "/tmp/repo-b" let store = TestStore( - initialState: WorkspaceCreationPromptFeature.State( + initialState: WorkspaceEditorFeature.State( repositories: [ ProjectWorkspaceCreationRepository( id: repoRootA, @@ -1298,10 +1300,10 @@ struct RepositoriesFeatureTests { rootPath: "/tmp/workspace" ) ) { - WorkspaceCreationPromptFeature() + WorkspaceEditorFeature() } - await store.send(.createButtonTapped) { + await store.send(.submitButtonTapped) { $0.validationMessage = "Choose an existing branch for Repo A." $0.validationTarget = .repository(repoRootA, .baseRef) $0.validationRequestID = 1 @@ -1313,10 +1315,10 @@ struct RepositoriesFeatureTests { } } - @Test func workspaceCreationPromptRequiresBranchNameForCreateBranchMode() async { + @Test func workspaceEditorRequiresBranchNameForCreateBranchMode() async { let repoRootB = "/tmp/repo-b" let store = TestStore( - initialState: WorkspaceCreationPromptFeature.State( + initialState: WorkspaceEditorFeature.State( repositories: [ ProjectWorkspaceCreationRepository( id: "remote", @@ -1337,10 +1339,10 @@ struct RepositoriesFeatureTests { rootPath: "/tmp/workspace" ) ) { - WorkspaceCreationPromptFeature() + WorkspaceEditorFeature() } - await store.send(.createButtonTapped) { + await store.send(.submitButtonTapped) { $0.validationMessage = "Branch name required for Remote." $0.validationTarget = .repository("remote", .branchName) $0.validationRequestID = 1 @@ -1352,10 +1354,10 @@ struct RepositoriesFeatureTests { } } - @Test func workspaceCreationPromptResetsLinkModeWhenSwitchingToBare() async { + @Test func workspaceEditorResetsLinkModeWhenSwitchingToBare() async { let repositoryID = "repo" let store = TestStore( - initialState: WorkspaceCreationPromptFeature.State( + initialState: WorkspaceEditorFeature.State( repositories: [ ProjectWorkspaceCreationRepository( id: repositoryID, @@ -1367,7 +1369,7 @@ struct RepositoriesFeatureTests { rootPath: "/tmp/workspace" ) ) { - WorkspaceCreationPromptFeature() + WorkspaceEditorFeature() } await store.send(.repositorySourceKindChanged(repositoryID, .bareRepository)) { @@ -1745,19 +1747,19 @@ struct RepositoriesFeatureTests { @Test func workspaceCreationCancelWhileCreatingShowsToast() async { var initialState = RepositoriesFeature.State() - initialState.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( + initialState.workspaceEditor = WorkspaceEditorFeature.State( repositories: [], title: "Workspace", rootPath: "/tmp/workspace" ) - initialState.workspaceCreationPrompt?.isCreating = true + initialState.workspaceEditor?.isSaving = true let store = TestStore(initialState: initialState) { RepositoriesFeature() } store.exhaustivity = .off await store.send(.workspaceCreation(.promptCanceled)) { - $0.workspaceCreationPrompt = nil + $0.workspaceEditor = nil } await store.receive(\.showToast, .warning("Workspace creation canceled")) } @@ -1816,7 +1818,7 @@ struct RepositoriesFeatureTests { .standardizedFileURL defer { try? FileManager.default.removeItem(at: rootURL) } var initialState = RepositoriesFeature.State() - initialState.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( + initialState.workspaceEditor = WorkspaceEditorFeature.State( repositories: [], title: "Feature", rootPath: rootURL.path(percentEncoded: false) @@ -1856,10 +1858,10 @@ struct RepositoriesFeatureTests { ) await store.send(.workspaceCreation(.createWorkspace(draft))) { - $0.workspaceCreationPrompt?.isCreating = true + $0.workspaceEditor?.isSaving = true } await store.receive(\.workspaceCreation.workspaceCreated) { - $0.workspaceCreationPrompt = nil + $0.workspaceEditor = nil } await store.receive(\.showToast, .success("Workspace created")) { $0.statusToast = .success("Workspace created") @@ -1873,7 +1875,7 @@ struct RepositoriesFeatureTests { @Test func workspaceBaseRefsLoadErrorSurfacesValidationMessage() async { let repositoryID = "/tmp/repo" var initialState = RepositoriesFeature.State() - initialState.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( + initialState.workspaceEditor = WorkspaceEditorFeature.State( repositories: [ ProjectWorkspaceCreationRepository( id: repositoryID, @@ -1900,7 +1902,7 @@ struct RepositoriesFeatureTests { ) ) ) { - $0.workspaceCreationPrompt?.validationMessage = "Could not read branches for Repo: boom" + $0.workspaceEditor?.validationMessage = "Could not read branches for Repo: boom" } } diff --git a/supacodeTests/RepositoriesFeatureWorkspaceEditingTests.swift b/supacodeTests/RepositoriesFeatureWorkspaceEditingTests.swift new file mode 100644 index 00000000..da5caca6 --- /dev/null +++ b/supacodeTests/RepositoriesFeatureWorkspaceEditingTests.swift @@ -0,0 +1,326 @@ +import ComposableArchitecture +import Foundation +import IdentifiedCollections +import Testing + +@testable import supacode + +@MainActor +struct RepositoriesFeatureWorkspaceEditingTests { + @Test func promptRequestedReadsMetadataFromDiskAndPresentsEditor() async throws { + let rootURL = try makeWorkspaceOnDisk( + title: "Disk Title", + repositories: [ProjectWorkspaceRepositoryEntry(id: "app", name: "App", path: "app")] + ) + defer { try? FileManager.default.removeItem(at: rootURL) } + let repositoryID = rootURL.path(percentEncoded: false) + // The in-memory snapshot is stale on purpose: the editor must show the file. + let snapshot = ProjectWorkspace( + id: repositoryID, title: "Stale Title", + repositories: [ProjectWorkspaceRepositoryEntry(id: "app", name: "App", path: "app")]) + let workspace = Repository( + id: repositoryID, rootURL: rootURL, name: "Stale Title", kind: .plain, worktrees: [], + workspace: snapshot) + let opened = Repository( + id: "/tmp/opened", rootURL: URL(fileURLWithPath: "/tmp/opened"), name: "Opened", kind: .git, + worktrees: []) + var initialState = RepositoriesFeature.State() + initialState.repositories = [workspace, opened] + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.uuid = .incrementing + } + + let loaded = try #require(ProjectWorkspace.load(from: rootURL)) + await store.send(.workspaceEditing(.promptRequested(repositoryID, removingChildID: nil))) + await store.receive(\.workspaceEditing.promptLoaded) { + $0.workspaceEditor = WorkspaceEditorFeature.State( + editing: loaded, + rootURL: rootURL, + repositoryID: repositoryID, + openedRepositoryCandidates: [ + ProjectWorkspaceCreationRepository( + id: "/tmp/opened", name: "Opened", rootURL: URL(fileURLWithPath: "/tmp/opened")) + ] + ) + } + #expect(store.state.workspaceEditor?.title == "Disk Title") + #expect(store.state.workspaceEditor?.mode == .edit(repositoryID: repositoryID)) + } + + @Test func promptRequestedWithChildPreMarksThatMemberForRemoval() async throws { + let rootURL = try makeWorkspaceOnDisk( + title: "Two", + repositories: [ + ProjectWorkspaceRepositoryEntry(id: "app", name: "App", path: "app"), + ProjectWorkspaceRepositoryEntry(id: "api", name: "API", path: "api"), + ] + ) + defer { try? FileManager.default.removeItem(at: rootURL) } + let repositoryID = rootURL.path(percentEncoded: false) + let loaded = try #require(ProjectWorkspace.load(from: rootURL)) + var initialState = RepositoriesFeature.State() + initialState.repositories = [ + Repository(id: repositoryID, rootURL: rootURL, name: "Two", kind: .plain, worktrees: [], workspace: loaded) + ] + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.uuid = .incrementing + } + store.exhaustivity = .off + let apiPath = rootURL.appending(path: "api").standardizedFileURL.path(percentEncoded: false) + + await store.send(.workspaceEditing(.promptRequested(repositoryID, removingChildID: apiPath))) + await store.receive(\.workspaceEditing.promptLoaded) + #expect(store.state.workspaceEditor?.existingRepositories[id: "api"]?.isMarkedForRemoval == true) + #expect(store.state.workspaceEditor?.existingRepositories[id: "app"]?.isMarkedForRemoval == false) + } + + @Test func promptRequestedIgnoresNonWorkspaceAndAlreadyOpenEditor() async { + var initialState = RepositoriesFeature.State() + initialState.repositories = [ + Repository(id: "/tmp/git", rootURL: URL(fileURLWithPath: "/tmp/git"), name: "Git", kind: .git, worktrees: []) + ] + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } + + await store.send(.workspaceEditing(.promptRequested("/tmp/git", removingChildID: nil))) + } + + @Test func saveWorkspaceWritesMetadataReloadsAndToasts() async throws { + let rootURL = try makeWorkspaceOnDisk( + title: "Before", + repositories: [ProjectWorkspaceRepositoryEntry(id: "app", name: "App", path: "app")] + ) + defer { try? FileManager.default.removeItem(at: rootURL) } + let repositoryID = rootURL.path(percentEncoded: false) + let loaded = try #require(ProjectWorkspace.load(from: rootURL)) + var initialState = RepositoriesFeature.State() + initialState.repositories = [ + Repository(id: repositoryID, rootURL: rootURL, name: "Before", kind: .plain, worktrees: [], workspace: loaded) + ] + initialState.repositoryRoots = [rootURL] + initialState.workspaceEditor = WorkspaceEditorFeature.State( + editing: loaded, rootURL: rootURL, repositoryID: repositoryID) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.shellClient.runLoginImpl = { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + $0.repositoryPersistence.loadRepositoryEntries = { + [PersistedRepositoryEntry(path: rootURL.path(percentEncoded: false), kind: .plain)] + } + $0.repositoryPersistence.saveRepositoryEntries = { _ in } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + } + store.exhaustivity = .off + + let request = ProjectWorkspaceUpdateRequest( + rootURL: rootURL, + title: "After", + description: "Edited", + members: [.existing(loaded.repositories[0])], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + await store.send(.workspaceEditing(.saveWorkspace(request))) { + $0.workspaceEditor?.isSaving = true + } + await store.receive(\.workspaceEditing.workspaceSaved) { + $0.workspaceEditor = nil + } + // The toast and the reload are merged effects with no ordering guarantee, + // so only the reload is awaited explicitly (non-exhaustive skips the rest). + await store.receive(\.repositoriesLoaded, timeout: .seconds(5)) + await store.finish() + + #expect(ProjectWorkspace.load(from: rootURL)?.title == "After") + #expect(ProjectWorkspace.load(from: rootURL)?.description == "Edited") + // The reload normalizes the persisted path (symlinks resolved), so look + // the workspace up by kind rather than by the pre-normalization id. + #expect(store.state.repositories.first(where: \.isWorkspace)?.name == "After") + #expect(store.state.alert == nil) + } + + @Test func saveWorkspaceReportsCleanupFailuresInAlert() async throws { + let outsideURL = FileManager.default.temporaryDirectory + .appending(path: "prowl-outside-\(UUID().uuidString)", directoryHint: .isDirectory) + .standardizedFileURL + try FileManager.default.createDirectory(at: outsideURL, withIntermediateDirectories: true) + let outsidePath = outsideURL.path(percentEncoded: false) + let rootURL = try makeWorkspaceOnDisk( + title: "Cleanup", + repositories: [ + ProjectWorkspaceRepositoryEntry(id: "app", name: "App", path: "app"), + ProjectWorkspaceRepositoryEntry(id: "ext", name: "External", path: outsidePath), + ] + ) + defer { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: outsideURL) + } + let repositoryID = rootURL.path(percentEncoded: false) + let loaded = try #require(ProjectWorkspace.load(from: rootURL)) + var initialState = RepositoriesFeature.State() + initialState.repositories = [ + Repository(id: repositoryID, rootURL: rootURL, name: "Cleanup", kind: .plain, worktrees: [], workspace: loaded) + ] + initialState.repositoryRoots = [rootURL] + initialState.workspaceEditor = WorkspaceEditorFeature.State( + editing: loaded, rootURL: rootURL, repositoryID: repositoryID) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.shellClient.runLoginImpl = { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + $0.repositoryPersistence.loadRepositoryEntries = { + [PersistedRepositoryEntry(path: rootURL.path(percentEncoded: false), kind: .plain)] + } + $0.repositoryPersistence.saveRepositoryEntries = { _ in } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + } + store.exhaustivity = .off + + let ext = try #require(loaded.repositories.first { $0.id == "ext" }) + await store.send( + .workspaceEditing( + .saveWorkspace( + ProjectWorkspaceUpdateRequest( + rootURL: rootURL, + title: "Cleanup", + members: [.existing(loaded.repositories[0])], + removals: [ProjectWorkspaceRepositoryRemoval(entry: ext, deleteFiles: true)], + updatedAt: Date() + )))) + await store.receive(\.workspaceEditing.workspaceSaved) { + $0.workspaceEditor = nil + } + await store.finish() + + #expect(store.state.alert != nil) + #expect(FileManager.default.fileExists(atPath: outsidePath)) + #expect(ProjectWorkspace.load(from: rootURL)?.repositories.map(\.id) == ["app"]) + } + + @Test func saveWorkspaceFailureKeepsEditorOpenWithMessage() async throws { + let missingRoot = FileManager.default.temporaryDirectory + .appending(path: "prowl-missing-\(UUID().uuidString)") + .standardizedFileURL + let repositoryID = missingRoot.path(percentEncoded: false) + let workspace = ProjectWorkspace( + id: repositoryID, title: "Gone", + repositories: [ProjectWorkspaceRepositoryEntry(id: "app", name: "App", path: "app")]) + var initialState = RepositoriesFeature.State() + initialState.repositories = [ + Repository( + id: repositoryID, rootURL: missingRoot, name: "Gone", kind: .plain, worktrees: [], workspace: workspace) + ] + initialState.workspaceEditor = WorkspaceEditorFeature.State( + editing: workspace, rootURL: missingRoot, repositoryID: repositoryID) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.shellClient.runLoginImpl = { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + } + + let request = ProjectWorkspaceUpdateRequest( + rootURL: missingRoot, title: "Gone", members: [.existing(workspace.repositories[0])], updatedAt: Date()) + await store.send(.workspaceEditing(.saveWorkspace(request))) { + $0.workspaceEditor?.isSaving = true + } + await store.receive(\.workspaceEditing.workspaceSaveFailed) { + $0.workspaceEditor?.isSaving = false + $0.workspaceEditor?.validationMessage = + ProjectWorkspaceCreationError.workspaceMetadataMissing(repositoryID).localizedDescription + } + // Cancel is ignored only while saving; afterwards it closes the editor. + await store.send(.workspaceEditor(.presented(.delegate(.cancel)))) + await store.receive(\.workspaceEditing.promptCanceled) { + $0.workspaceEditor = nil + } + } + + @Test func saveFailureWithoutEditorShowsAlert() async { + // `ifLet` clears the sheet state on `.dismiss`, so a failure that lands + // after the sheet is gone must still surface somewhere. + let store = TestStore(initialState: RepositoriesFeature.State()) { + RepositoriesFeature() + } + + await store.send(.workspaceEditing(.workspaceSaveFailed("boom"))) { + $0.alert = AlertState { + TextState("Unable to save workspace") + } actions: { + ButtonState(role: .cancel) { + TextState("OK") + } + } message: { + TextState("boom") + } + } + } + + @Test func cancelWhileSavingIsIgnored() async { + let rootURL = URL(fileURLWithPath: "/tmp/ws") + let workspace = ProjectWorkspace( + id: "/tmp/ws", title: "Busy", + repositories: [ProjectWorkspaceRepositoryEntry(id: "app", name: "App", path: "app")]) + var initialState = RepositoriesFeature.State() + initialState.workspaceEditor = WorkspaceEditorFeature.State( + editing: workspace, rootURL: rootURL, repositoryID: "/tmp/ws") + initialState.workspaceEditor?.isSaving = true + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } + + await store.send(.workspaceEditor(.presented(.delegate(.cancel)))) + await store.receive(\.workspaceEditing.promptCanceled) + #expect(store.state.workspaceEditor != nil) + } + + @Test func editorSubmitInEditModeRoutesToSave() async { + let rootURL = URL(fileURLWithPath: "/tmp/ws-missing-\(UUID().uuidString)", isDirectory: true) + let repositoryID = rootURL.path(percentEncoded: false) + let workspace = ProjectWorkspace( + id: repositoryID, title: "Route", + repositories: [ProjectWorkspaceRepositoryEntry(id: "app", name: "App", path: "app")]) + var initialState = RepositoriesFeature.State() + initialState.workspaceEditor = WorkspaceEditorFeature.State( + editing: workspace, rootURL: rootURL, repositoryID: repositoryID) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.date.now = Date(timeIntervalSince1970: 1_700_000_000) + $0.shellClient.runLoginImpl = { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + } + store.exhaustivity = .off + + await store.send(.workspaceEditor(.presented(.submitButtonTapped))) + await store.receive(\.workspaceEditor.presented.delegate.submit) + await store.receive(\.workspaceEditing.saveWorkspace) { + $0.workspaceEditor?.isSaving = true + } + await store.receive(\.workspaceEditing.workspaceSaveFailed) { + $0.workspaceEditor?.isSaving = false + } + } + + // MARK: - Helpers + + private func makeWorkspaceOnDisk( + title: String, + repositories: [ProjectWorkspaceRepositoryEntry] + ) throws -> URL { + // No directory hint: the id must match how repository loading derives + // ids (no trailing slash). + let rootURL = FileManager.default.temporaryDirectory + .appending(path: "prowl-editing-\(UUID().uuidString)") + .standardizedFileURL + try FileManager.default.createDirectory( + at: rootURL.appending(path: ProjectWorkspace.metadataDirectoryName), withIntermediateDirectories: true) + let workspace = ProjectWorkspace(title: title, repositories: repositories) + try ProjectWorkspace.encodeMetadata(workspace, preservingUnknownKeysIn: nil) + .write(to: ProjectWorkspace.metadataURL(for: rootURL)) + return rootURL + } +} diff --git a/supacodeTests/WorkspaceEditorFeatureTests.swift b/supacodeTests/WorkspaceEditorFeatureTests.swift new file mode 100644 index 00000000..e028148e --- /dev/null +++ b/supacodeTests/WorkspaceEditorFeatureTests.swift @@ -0,0 +1,315 @@ +import ComposableArchitecture +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct WorkspaceEditorFeatureTests { + private let rootURL = URL(fileURLWithPath: "/tmp/workspaces/checkout-flow", isDirectory: true) + + private var appEntry: ProjectWorkspaceRepositoryEntry { + ProjectWorkspaceRepositoryEntry( + id: "app", + name: "App", + role: "macOS app", + path: "app", + sourceKind: .existingPath, + sourceLocation: "/tmp/source/app", + branchName: "codex/checkout", + baseRef: "main" + ) + } + + private var apiEntry: ProjectWorkspaceRepositoryEntry { + ProjectWorkspaceRepositoryEntry( + id: "api", + name: "API", + path: "api", + sourceKind: .remote, + sourceLocation: "git@github.com:onevcat/api.git", + baseRef: "origin/main" + ) + } + + private var workspace: ProjectWorkspace { + ProjectWorkspace( + id: rootURL.path(percentEncoded: false), + title: "Checkout Flow", + description: "Ship the flow", + taskLinks: ["https://example.com/issues/1"], + repositories: [appEntry, apiEntry] + ) + } + + private func makeEditState() -> WorkspaceEditorFeature.State { + WorkspaceEditorFeature.State( + editing: workspace, + rootURL: rootURL, + repositoryID: "/tmp/workspaces/checkout-flow", + openedRepositoryCandidates: [ + ProjectWorkspaceCreationRepository( + id: "/tmp/source/shared", + name: "Shared", + rootURL: URL(fileURLWithPath: "/tmp/source/shared") + ) + ] + ) + } + + @Test func editModePopulatesFromWorkspaceMetadata() { + let state = makeEditState() + + #expect(state.mode == .edit(repositoryID: "/tmp/workspaces/checkout-flow")) + #expect(state.title == "Checkout Flow") + #expect(state.description == "Ship the flow") + #expect(state.taskLinks.map(\.value) == ["https://example.com/issues/1"]) + #expect(state.taskLinks.map(\.id) == ["task-link-0"]) + #expect(state.rootPath == "/tmp/workspaces/checkout-flow") + #expect(state.isRootPathDirty) + #expect(state.existingRepositories.map(\.id) == ["app", "api"]) + #expect(state.existingRepositories[id: "app"]?.role == "macOS app") + #expect(state.existingRepositories[id: "api"]?.role == "") + #expect(state.existingRepositories[id: "app"]?.offersBranchDeletion == true) + #expect(state.existingRepositories[id: "api"]?.offersBranchDeletion == false) + #expect(state.repositories.isEmpty) + #expect(state.remainingRepositoryCount == 2) + } + + @Test func titleChangeInEditModeKeepsRootPath() async { + let store = TestStore(initialState: makeEditState()) { + WorkspaceEditorFeature() + } + + await store.send(.titleChanged("Renamed")) { + $0.title = "Renamed" + } + #expect(store.state.rootPath == "/tmp/workspaces/checkout-flow") + } + + @Test func taskLinksCanBeAddedEditedAndRemoved() async { + let store = TestStore(initialState: makeEditState()) { + WorkspaceEditorFeature() + } withDependencies: { + $0.uuid = .incrementing + } + + await store.send(.addTaskLinkButtonTapped) { + $0.taskLinks.append(WorkspaceTaskLinkDraft(id: UUID(0).uuidString, value: "")) + } + await store.send(.taskLinkChanged(UUID(0).uuidString, "PROWL-42")) { + $0.taskLinks[id: UUID(0).uuidString]?.value = "PROWL-42" + } + await store.send(.removeTaskLink("task-link-0")) { + $0.taskLinks.remove(id: "task-link-0") + } + #expect(store.state.taskLinks.map(\.value) == ["PROWL-42"]) + } + + @Test func removalMarkingGatesDeleteFlagsAndCanBeUndone() async { + let store = TestStore(initialState: makeEditState()) { + WorkspaceEditorFeature() + } + + // Flags on an unmarked member are ignored. + await store.send(.existingRepositoryDeleteFilesChanged("app", true)) + await store.send(.existingRepositoryMarkedForRemoval("app")) { + $0.existingRepositories[id: "app"]?.removal = .init() + } + #expect(store.state.remainingRepositoryCount == 1) + #expect(store.state.hasPendingRemovals) + // Branch deletion requires file deletion. + await store.send(.existingRepositoryDeleteBranchChanged("app", true)) + await store.send(.existingRepositoryDeleteFilesChanged("app", true)) { + $0.existingRepositories[id: "app"]?.removal?.deleteFiles = true + } + await store.send(.existingRepositoryDeleteBranchChanged("app", true)) { + $0.existingRepositories[id: "app"]?.removal?.deleteBranch = true + } + // Turning file deletion off clears the branch choice. + await store.send(.existingRepositoryDeleteFilesChanged("app", false)) { + $0.existingRepositories[id: "app"]?.removal = .init(deleteFiles: false, deleteBranch: false) + } + await store.send(.existingRepositoryRemovalUndone("app")) { + $0.existingRepositories[id: "app"]?.removal = nil + } + // A clone has no branch to delete. + await store.send(.existingRepositoryMarkedForRemoval("api")) { + $0.existingRepositories[id: "api"]?.removal = .init() + } + await store.send(.existingRepositoryDeleteFilesChanged("api", true)) { + $0.existingRepositories[id: "api"]?.removal?.deleteFiles = true + } + await store.send(.existingRepositoryDeleteBranchChanged("api", true)) + } + + @Test func existingMembersCanBeReordered() async { + let store = TestStore(initialState: makeEditState()) { + WorkspaceEditorFeature() + } + + await store.send(.existingRepositoryMovedUp("app")) + await store.send(.existingRepositoryMovedDown("app")) { + $0.existingRepositories = [ + WorkspaceEditorExistingRepository(entry: self.apiEntry), + WorkspaceEditorExistingRepository(entry: self.appEntry), + ] + } + await store.send(.existingRepositoryMovedDown("app")) + await store.send(.existingRepositoryMovedUp("app")) { + $0.existingRepositories = [ + WorkspaceEditorExistingRepository(entry: self.appEntry), + WorkspaceEditorExistingRepository(entry: self.apiEntry), + ] + } + } + + @Test func submitRefusesToRemoveTheLastMember() async { + var state = makeEditState() + state.existingRepositories[id: "app"]?.removal = .init() + state.existingRepositories[id: "api"]?.removal = .init() + let store = TestStore(initialState: state) { + WorkspaceEditorFeature() + } + + await store.send(.submitButtonTapped) { + $0.validationMessage = "Add at least one repository." + $0.validationTarget = nil + $0.validationRequestID = 1 + } + } + + @Test func submitInEditModeBuildsUpdateRequest() async { + let now = Date(timeIntervalSince1970: 1_700_000_000) + var state = makeEditState() + state.title = " Checkout Flow v2 " + state.description = " Ship it \n" + state.taskLinks = [ + WorkspaceTaskLinkDraft(id: "a", value: " https://example.com/issues/2 "), + WorkspaceTaskLinkDraft(id: "b", value: " "), + ] + state.existingRepositories[id: "app"]?.name = " Mac App " + state.existingRepositories[id: "app"]?.role = "" + state.existingRepositories[id: "api"]?.removal = .init(deleteFiles: true, deleteBranch: false) + state.repositories = [ + { + var repository = ProjectWorkspaceCreationRepository( + id: "/tmp/source/shared", + name: "Shared", + rootURL: URL(fileURLWithPath: "/tmp/source/shared") + ) + repository.role = " library " + return repository + }() + ] + let store = TestStore(initialState: state) { + WorkspaceEditorFeature() + } withDependencies: { + $0.date.now = now + } + + var renamedApp = appEntry + renamedApp.name = "Mac App" + renamedApp.role = nil + await store.send(.submitButtonTapped) + await store.receive( + .delegate( + .submit( + .update( + ProjectWorkspaceUpdateRequest( + rootURL: rootURL, + title: "Checkout Flow v2", + description: "Ship it", + taskLinks: ["https://example.com/issues/2"], + members: [ + .existing(renamedApp), + .added( + ProjectWorkspaceRepositoryPlan( + id: "/tmp/source/shared", + name: "Shared", + role: "library", + path: nil, + sourceKind: .existingPath, + sourceLocation: "/tmp/source/shared", + checkout: .link + )), + ], + removals: [ + ProjectWorkspaceRepositoryRemoval(entry: apiEntry, deleteFiles: true, deleteBranch: false) + ], + updatedAt: now + ) + ) + ) + ) + ) + } + + @Test func submitInCreateModeCarriesDescriptionLinksAndRole() async { + var repository = ProjectWorkspaceCreationRepository( + id: "/tmp/repo-a", + name: "Repo A", + rootURL: URL(fileURLWithPath: "/tmp/repo-a") + ) + repository.role = "app" + var state = WorkspaceEditorFeature.State( + repositories: [repository], + title: "Solo", + rootPath: "/tmp/solo-workspace" + ) + state.description = "Just one" + state.taskLinks = [WorkspaceTaskLinkDraft(id: "a", value: "PROWL-1")] + let store = TestStore(initialState: state) { + WorkspaceEditorFeature() + } + + await store.send(.submitButtonTapped) + await store.receive( + .delegate( + .submit( + .create( + ProjectWorkspaceCreationDraft( + title: "Solo", + description: "Just one", + taskLinks: ["PROWL-1"], + rootURL: URL(filePath: "/tmp/solo-workspace", directoryHint: .isDirectory), + repositories: [ + ProjectWorkspaceRepositoryPlan( + id: "/tmp/repo-a", + name: "Repo A", + role: "app", + path: nil, + sourceKind: .existingPath, + sourceLocation: "/tmp/repo-a", + checkout: .link + ) + ] + ) + ) + ) + ) + ) + } + + @Test func submitValidatesAddedRowsBeforeBuildingUpdate() async { + var state = makeEditState() + state.repositories = [ + ProjectWorkspaceCreationRepository( + id: "remote", + name: "Remote", + sourceKind: .remote, + sourceLocation: "git@github.com:onevcat/x.git", + checkoutMode: .useExistingRef + ) + ] + let store = TestStore(initialState: state) { + WorkspaceEditorFeature() + } + + await store.send(.submitButtonTapped) { + $0.validationMessage = "Choose an existing branch for Remote." + $0.validationTarget = .repository("remote", .baseRef) + $0.validationRequestID = 1 + } + } +} -- 2.51.2 From 5a5269417d4ef73615073b3071f4eac6bfee480d Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 25 Sep 2026 17:06:51 +0800 Subject: [PATCH 04/15] Open the workspace editor from the sidebar, detail view, Settings, palette, and menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace header and child-row context menus gain Edit Workspace… (and Remove from Workspace…, which pre-marks that child); the detail view gets an Edit Workspace… button and clickable http(s) task links; the Settings page replaces its read-only note with the same button, surfacing the main window first; the Command Palette offers Edit Workspace for the active workspace; the Worktrees menu offers it when a workspace is selected. --- supacode/Commands/WorktreeCommands.swift | 14 +++ .../Reducer/AppFeature+CommandPalette.swift | 4 + .../Features/App/Reducer/AppFeature.swift | 11 ++ .../CommandPalette/CommandPaletteItem.swift | 2 + .../Reducer/CommandPaletteFeature.swift | 49 ++++++-- .../Reducer/CommandPaletteSupport.swift | 22 +++- .../Views/CommandPaletteOverlayView.swift | 7 +- .../Views/RepositoryDetailView.swift | 3 +- .../Views/RepositorySectionView.swift | 13 +++ .../Views/WorkspaceChildRowsView.swift | 11 ++ .../Views/WorkspaceDetailView.swift | 37 +++++- .../Views/WorktreeDetailView.swift | 6 +- .../Reducer/RepositorySettingsFeature.swift | 7 ++ .../Settings/Reducer/SettingsFeature.swift | 4 + .../Views/RepositorySettingsView.swift | 16 ++- .../AppFeatureWorkspaceEditingTests.swift | 109 ++++++++++++++++++ .../CommandPaletteFeatureTests.swift | 4 +- 17 files changed, 291 insertions(+), 28 deletions(-) create mode 100644 supacodeTests/AppFeatureWorkspaceEditingTests.swift diff --git a/supacode/Commands/WorktreeCommands.swift b/supacode/Commands/WorktreeCommands.swift index 2bcab013..c524236b 100644 --- a/supacode/Commands/WorktreeCommands.swift +++ b/supacode/Commands/WorktreeCommands.swift @@ -103,6 +103,13 @@ struct WorktreeCommands: Commands { store.send(.repositories(.workspaceCreation(.promptRequested))) } .help("New Workspace") + Button("Edit Workspace...", systemImage: "folder.badge.gearshape") { + if let workspaceID = selectedWorkspaceID { + store.send(.repositories(.workspaceEditing(.promptRequested(workspaceID, removingChildID: nil)))) + } + } + .help("Edit the selected workspace's title, description, links, and repositories") + .disabled(selectedWorkspaceID == nil) Button("Open Worktree") { openSelectedWorktreeAction?() } @@ -175,6 +182,13 @@ struct WorktreeCommands: Commands { AppShortcuts.worktreeSelectionCommandIDs } + private var selectedWorkspaceID: Repository.ID? { + guard let repository = store.repositories.selectedRepository, repository.isWorkspace else { + return nil + } + return repository.id + } + private var selectedCodeHostWorktreeID: Worktree.ID? { codeHostWorktreeID( repositories: store.repositories, diff --git a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift index 478d8c78..8b46c8a3 100644 --- a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift +++ b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift @@ -125,6 +125,10 @@ extension AppFeature { case .newWorkspace: return .send(.repositories(.workspaceCreation(.promptRequested))) + case .editWorkspace(let repositoryID): + return .send( + .repositories(.workspaceEditing(.promptRequested(repositoryID, removingChildID: nil)))) + default: return nil } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index e9cc53e2..724e17c4 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -578,6 +578,17 @@ struct AppFeature { case .settings(.delegate(.terminalFontSizeChanged)): return .none + case .settings(.delegate(.editWorkspace(let repositoryID))): + guard state.repositories.repositories[id: repositoryID]?.isWorkspace == true else { + return .none + } + // The editor is a sheet on the main window; bring it forward from + // the Settings window first so the sheet is not opened out of sight. + return .merge( + .run { _ in _ = await appLifecycleClient.surfaceMainWindow() }, + .send(.repositories(.workspaceEditing(.promptRequested(repositoryID, removingChildID: nil)))) + ) + case .settings(.delegate(.cliInstallCompleted(let result))): switch result { case .installed(let path): diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index c3c02357..5fe5592c 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -87,6 +87,7 @@ struct CommandPaletteItem: Identifiable, Equatable { case deleteWorktree(Worktree.ID, Repository.ID) case renameBranch case openRepositorySettings(Repository.ID) + case editWorkspace(Repository.ID) case runCustomCommand(EffectiveCustomCommand.Identifier, systemImage: String) case launchAgentProfile(AgentProfile.ID) case runWorkflow(String) @@ -176,6 +177,7 @@ struct CommandPaletteItem: Identifiable, Equatable { .togglePinWorktree, .deleteWorktree, .openRepositorySettings, + .editWorkspace, .runCustomCommand, .launchAgentProfile, .runWorkflow: diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 46f38f01..6c4ad226 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -72,6 +72,7 @@ struct CommandPaletteFeature { case togglePinWorktree(Worktree.ID, isCurrentlyPinned: Bool) case renameBranch case openRepositorySettings(Repository.ID) + case editWorkspace(Repository.ID) case runCustomCommand(EffectiveCustomCommand.Identifier) case launchAgentProfile(AgentProfile.ID) case runWorkflow(String) @@ -297,17 +298,7 @@ struct CommandPaletteFeature { items.append(contentsOf: ghosttyCommandItems(ghosttyCommands)) } if let repository = activeRepository(in: repositories) { - items.append( - CommandPaletteItem( - id: CommandPaletteItemID.openRepositorySettings(repository.id), - title: String(localized: "Repo Settings"), - subtitle: repository.name, - kind: .openRepositorySettings(repository.id), - category: .app, - defaultSuggestion: true, - keywords: ["Repo Settings", "repo", "settings", "configure", "preferences", "仓库", "设置", "偏好设置"] - ) - ) + items.append(contentsOf: activeRepositoryItems(for: repository)) } items.append( contentsOf: selectedCodeHostItems( @@ -331,6 +322,9 @@ struct CommandPaletteFeature { for repository in repositories { ids.append(contentsOf: CommandPaletteItemID.pullRequestIDs(repositoryID: repository.id)) ids.append(CommandPaletteItemID.openRepositorySettings(repository.id)) + if repository.isWorkspace { + ids.append(CommandPaletteItemID.editWorkspace(repository.id)) + } for worktree in repository.worktrees { ids.append(CommandPaletteItemID.worktreeSelect(worktree.id)) ids.append(CommandPaletteItemID.changeFocusedTabIcon(worktree.id)) @@ -357,6 +351,39 @@ private func selectableWorktreeItems( } } +/// Repo-scoped app items: settings for every repository, plus the workspace +/// editor when the active repository is a workspace. +private func activeRepositoryItems(for repository: Repository) -> [CommandPaletteItem] { + var items = [ + CommandPaletteItem( + id: CommandPaletteItemID.openRepositorySettings(repository.id), + title: String(localized: "Repo Settings"), + subtitle: repository.name, + kind: .openRepositorySettings(repository.id), + category: .app, + defaultSuggestion: true, + keywords: ["Repo Settings", "repo", "settings", "configure", "preferences", "仓库", "设置", "偏好设置"] + ) + ] + if repository.isWorkspace { + items.append( + CommandPaletteItem( + id: CommandPaletteItemID.editWorkspace(repository.id), + title: String(localized: "Edit Workspace"), + subtitle: repository.name, + kind: .editWorkspace(repository.id), + category: .app, + defaultSuggestion: true, + keywords: [ + "Edit Workspace", "workspace", "add repository", "remove repository", "rename", + "工作区", "编辑工作区", "添加仓库", + ] + ) + ) + } + return items +} + private func globalCommandItems( showsNewWorktreeAction: Bool, isShowingArchivedWorktrees: Bool diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift index 75659a7c..438f06be 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift @@ -32,6 +32,10 @@ enum CommandPaletteItemID { static let globalRenameBranch = "global.rename-branch" static let globalDeleteWorktree = "global.delete-worktree" + static func editWorkspace(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { + "repo.\(repositoryID).edit-workspace" + } + static func openRepositorySettings(_ repositoryID: Repository.ID) -> CommandPaletteItem.ID { "repo.\(repositoryID).open-settings" } @@ -195,6 +199,7 @@ func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature. .newWorktree, .openRepository, .newWorkspace, + .editWorkspace, .viewArchivedWorktrees, .refreshWorktrees, .jumpToLatestUnread, @@ -242,6 +247,9 @@ func appDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeatu if let delegate = viewDelegateAction(for: kind) { return delegate } + if let delegate = workspaceDelegateAction(for: kind) { + return delegate + } switch kind { case .checkForUpdates: return .checkForUpdates @@ -251,8 +259,6 @@ func appDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeatu return .newWorktree case .openRepository: return .openRepository - case .newWorkspace: - return .newWorkspace case .viewArchivedWorktrees: return .viewArchivedWorktrees case .refreshWorktrees: @@ -272,6 +278,17 @@ func appDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeatu } } +func workspaceDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature.Delegate? { + switch kind { + case .newWorkspace: + return .newWorkspace + case .editWorkspace(let repositoryID): + return .editWorkspace(repositoryID) + default: + return nil + } +} + func navigationDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature.Delegate? { switch kind { case .revealInFinder: @@ -367,6 +384,7 @@ func pullRequestDelegateAction( .renameBranch, .deleteWorktree, .openRepositorySettings, + .editWorkspace, .runCustomCommand, .launchAgentProfile, .runWorkflow: diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 88bb737f..496bbc6c 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -527,7 +527,7 @@ private struct CommandPaletteRowView: View { .toggleShelf, .showDiff, .outgoingChanges, .revealInFinder, .copyPath, .revealInSidebar, .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, - .openRepositorySettings, .runCustomCommand, .launchAgentProfile, .runWorkflow: + .openRepositorySettings, .editWorkspace, .runCustomCommand, .launchAgentProfile, .runWorkflow: return nil case .deleteWorktree: return String(localized: "Delete") @@ -618,6 +618,8 @@ private struct CommandPaletteRowView: View { return "pencil" case .openRepositorySettings: return "gearshape" + case .editWorkspace: + return "folder.badge.gearshape" case .deleteWorktree: return "trash" case .runCustomCommand(_, let systemImage): @@ -651,6 +653,7 @@ private struct CommandPaletteRowView: View { .revealInFinder, .copyPath, .revealInSidebar, .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, .openRepositorySettings, + .editWorkspace, .deleteWorktree, .runCustomCommand, .launchAgentProfile, .runWorkflow: return true case .worktreeSelect: @@ -815,6 +818,8 @@ private struct CommandPaletteRowView: View { base = String(localized: "Rename Branch") case .openRepositorySettings: base = String(localized: "Open Repo Settings") + case .editWorkspace: + base = String(localized: "Edit Workspace") case .deleteWorktree: base = String(localized: "Delete \(row.title)") case .runCustomCommand: diff --git a/supacode/Features/Repositories/Views/RepositoryDetailView.swift b/supacode/Features/Repositories/Views/RepositoryDetailView.swift index ccef8b30..4731ff41 100644 --- a/supacode/Features/Repositories/Views/RepositoryDetailView.swift +++ b/supacode/Features/Repositories/Views/RepositoryDetailView.swift @@ -5,10 +5,11 @@ struct RepositoryDetailView: View { /// Resolved by the parent reducer. When non-nil, takes precedence /// over `repository.name` for display. var customTitle: String? + var onEditWorkspace: (() -> Void)? var body: some View { if let workspace = repository.workspace { - WorkspaceDetailView(repository: repository, workspace: workspace) + WorkspaceDetailView(repository: repository, workspace: workspace, onEdit: onEditWorkspace) } else { repositoryDetail } diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index c8b2902d..c394ec20 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -256,6 +256,12 @@ struct RepositorySectionView: View { onShowOutgoingChanges: { childID in store.send( .delegate(.showOutgoingChanges(.workspaceChild(workspaceID: repository.id, path: childID)))) + }, + onEditWorkspace: { + store.send(.workspaceEditing(.promptRequested(repository.id, removingChildID: nil))) + }, + onRemoveFromWorkspace: { childID in + store.send(.workspaceEditing(.promptRequested(repository.id, removingChildID: childID))) } ) } else { @@ -304,6 +310,13 @@ struct RepositorySectionView: View { } } Divider() + if repository.isWorkspace { + Button("Edit Workspace…") { + store.send(.workspaceEditing(.promptRequested(repository.id, removingChildID: nil))) + } + .help("Edit the workspace title, description, links, and repositories") + .disabled(isRemovingRepository) + } Button("Repo Settings…") { openRepoSettings() } diff --git a/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift index 374abd28..ed58f43c 100644 --- a/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift @@ -11,6 +11,8 @@ struct WorkspaceChildRowsView: View { let onSelect: (String) -> Void let onShowDiff: (String) -> Void let onShowOutgoingChanges: (String) -> Void + let onEditWorkspace: () -> Void + let onRemoveFromWorkspace: (String) -> Void var body: some View { ForEach(rows) { row in @@ -72,6 +74,15 @@ struct WorkspaceChildRowsView: View { onShowOutgoingChanges(row.id) } .help("Show committed changes relative to this repository's base") + Divider() + Button("Edit Workspace…") { + onEditWorkspace() + } + .help("Edit the workspace title, description, links, and repositories") + Button("Remove from Workspace…") { + onRemoveFromWorkspace(row.id) + } + .help("Open the workspace editor with \(row.repositoryName) marked for removal") } .id(row.id) } diff --git a/supacode/Features/Repositories/Views/WorkspaceDetailView.swift b/supacode/Features/Repositories/Views/WorkspaceDetailView.swift index 9bab292d..df6976c5 100644 --- a/supacode/Features/Repositories/Views/WorkspaceDetailView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceDetailView.swift @@ -3,6 +3,7 @@ import SwiftUI struct WorkspaceDetailView: View { let repository: Repository let workspace: ProjectWorkspace + var onEdit: (() -> Void)? var body: some View { VStack(alignment: .leading, spacing: 18) { @@ -42,6 +43,15 @@ struct WorkspaceDetailView: View { .font(.subheadline) .foregroundStyle(.tertiary) } + Spacer(minLength: 12) + if let onEdit { + Button { + onEdit() + } label: { + Label("Edit Workspace…", systemImage: "folder.badge.gearshape") + } + .help("Edit the workspace title, description, links, and repositories") + } } } @@ -55,10 +65,18 @@ struct WorkspaceDetailView: View { Text("Task Links") .font(.headline) ForEach(workspace.taskLinks, id: \.self) { link in - Text(link) - .font(.subheadline.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) + if let url = Self.openableURL(link) { + Link(destination: url) { + Text(link) + .font(.subheadline.monospaced()) + } + .help("Open \(link)") + } else { + Text(link) + .font(.subheadline.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } } } } @@ -70,4 +88,15 @@ struct WorkspaceDetailView: View { WorkspaceRepositoriesGridView(workspace: workspace, rootURL: repository.rootURL) } } + + /// Task links are free text (an issue key is as valid as a URL); only + /// values with a web scheme become clickable. + static func openableURL(_ link: String) -> URL? { + guard let url = URL(string: link), let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", url.host() != nil + else { + return nil + } + return url + } } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 38837cee..6872eaba 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -622,7 +622,11 @@ struct WorktreeDetailView: View { } else if let selectedRepository = repositories.selectedRepository { RepositoryDetailView( repository: selectedRepository, - customTitle: repositories.repositoryCustomTitles[selectedRepository.id] + customTitle: repositories.repositoryCustomTitles[selectedRepository.id], + onEditWorkspace: { + store.send( + .repositories(.workspaceEditing(.promptRequested(selectedRepository.id, removingChildID: nil)))) + } ) } else { EmptyStateView(store: store.scope(state: \.repositories, action: \.repositories)) diff --git a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift index d19d6313..9f6ac415 100644 --- a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift +++ b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift @@ -126,6 +126,7 @@ struct RepositorySettingsFeature { case setGlobalCommandEnabled(UserCustomCommand.ID, Bool) case setDefaultAgentProfileID(AgentProfile.ID?) case branchDataLoaded([String], defaultBaseRef: String) + case editWorkspaceTapped case workflows(WorkflowsSettingsFeature.Action) case delegate(Delegate) case binding(BindingAction) @@ -134,6 +135,8 @@ struct RepositorySettingsFeature { @CasePathable enum Delegate: Equatable { case settingsChanged(URL) + /// The workspace editor lives on the main window; the app surfaces it. + case editWorkspace(Repository.ID) } @Dependency(GitClientDependency.self) private var gitClient @@ -374,6 +377,10 @@ struct RepositorySettingsFeature { state.isBranchDataLoaded = true return .none + case .editWorkspaceTapped: + guard state.workspace != nil else { return .none } + return .send(.delegate(.editWorkspace(state.repositoryID))) + case .symbolPickerDismissed: state.isSymbolPickerPresented = false return reduceSymbolPickerDismissal(state: &state) diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index a4e2254e..31683885 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -275,6 +275,7 @@ struct SettingsFeature { case terminalFontSizeChanged(Float32?) case terminalLayoutSnapshotCleared(success: Bool) case cliInstallCompleted(CLIInstallResultMessage) + case editWorkspace(Repository.ID) } @Dependency(FeatureFlags.self) private var featureFlags @@ -616,6 +617,9 @@ struct SettingsFeature { case .alert: return .none + case .repositorySettings(.delegate(.editWorkspace(let repositoryID))): + return .send(.delegate(.editWorkspace(repositoryID))) + case .repositorySettings: return .none diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index af506caa..79934681 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -74,6 +74,12 @@ struct RepositorySettingsView: View { } } WorkspaceRepositoriesGridView(workspace: workspace, rootURL: store.rootURL) + Button { + store.send(.editWorkspaceTapped) + } label: { + Label("Edit Workspace…", systemImage: "folder.badge.gearshape") + } + .help("Open the workspace editor to change its title, description, links, and repositories") } .frame(maxWidth: .infinity, alignment: .leading) } header: { @@ -81,12 +87,10 @@ struct RepositorySettingsView: View { } footer: { let metadataPath = ProjectWorkspace.metadataURL(for: store.rootURL).path( percentEncoded: false) - Text( - "Read-only. Defined in \(metadataPath) — edit that file to change it." - ) - .font(.footnote) - .foregroundStyle(.secondary) - .textSelection(.enabled) + Text("Stored in \(metadataPath). Edit Workspace opens the editor in the main window.") + .font(.footnote) + .foregroundStyle(.secondary) + .textSelection(.enabled) } } diff --git a/supacodeTests/AppFeatureWorkspaceEditingTests.swift b/supacodeTests/AppFeatureWorkspaceEditingTests.swift new file mode 100644 index 00000000..70f4719c --- /dev/null +++ b/supacodeTests/AppFeatureWorkspaceEditingTests.swift @@ -0,0 +1,109 @@ +import ComposableArchitecture +import DependenciesTestSupport +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct AppFeatureWorkspaceEditingTests { + private func makeWorkspaceRepository(id: String) -> Repository { + Repository( + id: id, + rootURL: URL(fileURLWithPath: id), + name: "Workspace", + kind: .plain, + worktrees: [], + workspace: ProjectWorkspace( + title: "Workspace", + repositories: [ProjectWorkspaceRepositoryEntry(id: "app", name: "App", path: "app")]) + ) + } + + @Test(.dependencies) func settingsEditWorkspaceSurfacesMainWindowAndOpensEditor() async { + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [makeWorkspaceRepository(id: "/tmp/ws")] + let surfaced = LockIsolated(false) + let store = TestStore( + initialState: AppFeature.State(repositories: repositoriesState, settings: SettingsFeature.State()) + ) { + AppFeature() + } withDependencies: { + $0.uuid = .incrementing + $0.appLifecycleClient.surfaceMainWindow = { + surfaced.withValue { $0 = true } + return true + } + } + store.exhaustivity = .off + + await store.send(.settings(.delegate(.editWorkspace("/tmp/ws")))) + await store.receive(\.repositories.workspaceEditing.promptRequested) + await store.finish() + #expect(surfaced.value) + } + + @Test(.dependencies) func settingsEditWorkspaceIgnoresNonWorkspaceRepositories() async { + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [ + Repository(id: "/tmp/git", rootURL: URL(fileURLWithPath: "/tmp/git"), name: "Git", kind: .git, worktrees: []) + ] + let store = TestStore( + initialState: AppFeature.State(repositories: repositoriesState, settings: SettingsFeature.State()) + ) { + AppFeature() + } + + await store.send(.settings(.delegate(.editWorkspace("/tmp/git")))) + } + + @Test(.dependencies) func repositorySettingsEditTappedBubblesToSettingsDelegate() async { + let store = TestStore( + initialState: RepositorySettingsFeature.State( + rootURL: URL(fileURLWithPath: "/tmp/ws"), + repositoryID: "/tmp/ws", + repositoryKind: .plain, + workspace: ProjectWorkspace(title: "Workspace"), + settings: .default, + userSettings: .default) + ) { + RepositorySettingsFeature() + } + + await store.send(.editWorkspaceTapped) + await store.receive(\.delegate.editWorkspace) + } + + @Test(.dependencies) func paletteEditWorkspaceDispatchesPromptRequest() async { + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [makeWorkspaceRepository(id: "/tmp/ws")] + let store = TestStore( + initialState: AppFeature.State(repositories: repositoriesState, settings: SettingsFeature.State()) + ) { + AppFeature() + } withDependencies: { + $0.uuid = .incrementing + } + store.exhaustivity = .off + + await store.send(.commandPalette(.delegate(.editWorkspace("/tmp/ws")))) + await store.receive(\.repositories.workspaceEditing.promptRequested) + } + + @Test func paletteOffersEditWorkspaceOnlyForSelectedWorkspace() { + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [ + makeWorkspaceRepository(id: "/tmp/ws"), + Repository(id: "/tmp/git", rootURL: URL(fileURLWithPath: "/tmp/git"), name: "Git", kind: .git, worktrees: []), + ] + repositoriesState.selection = .repository("/tmp/ws") + + let workspaceItems = CommandPaletteFeature.commandPaletteItems(from: repositoriesState) + #expect(workspaceItems.contains { $0.kind == .editWorkspace("/tmp/ws") }) + + repositoriesState.selection = .repository("/tmp/git") + let repositoryItems = CommandPaletteFeature.commandPaletteItems(from: repositoriesState) + #expect(!repositoryItems.contains { $0.kind == .editWorkspace("/tmp/git") }) + #expect(!repositoryItems.contains { $0.kind == .editWorkspace("/tmp/ws") }) + } +} diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 3d342210..8fad80cf 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -1841,7 +1841,7 @@ private func makeItem( private func testCategory(for kind: CommandPaletteItem.Kind) -> CommandPaletteItem.Category { switch kind { case .checkForUpdates, .openSettings, .openRepository, .newWorkspace, .installCLI, - .openRepositorySettings: + .openRepositorySettings, .editWorkspace: return .app case .newWorktree, .refreshWorktrees, .viewArchivedWorktrees, .changeFocusedTabIcon, @@ -1880,7 +1880,7 @@ private func testDefaultSuggestion(for kind: CommandPaletteItem.Kind) -> Bool { .toggleShelf, .showDiff, .outgoingChanges, .revealInFinder, .copyPath, .revealInSidebar, .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, - .openRepositorySettings: + .openRepositorySettings, .editWorkspace: return true case .worktreeSelect, .changeFocusedTabIcon, .ghosttyCommand, .openRepositoryOnCodeHost, -- 2.51.2 From 96e2f3134e8bfb66d7ee292f3db52f02c8b985be Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 25 Sep 2026 17:06:52 +0800 Subject: [PATCH 05/15] Document workspace editing --- docs-ai/072-workspace-editing/000-plan.md | 2 +- docs-ai/072-workspace-editing/001-action.md | 89 +++++++++++++++++++ docs/components/command-palette.md | 6 +- docs/components/repositories-and-worktrees.md | 7 +- docs/components/settings.md | 2 +- docs/components/workspaces.md | 72 ++++++++++++++- 6 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 docs-ai/072-workspace-editing/001-action.md diff --git a/docs-ai/072-workspace-editing/000-plan.md b/docs-ai/072-workspace-editing/000-plan.md index 786e3747..fcd0ca48 100644 --- a/docs-ai/072-workspace-editing/000-plan.md +++ b/docs-ai/072-workspace-editing/000-plan.md @@ -2,7 +2,7 @@ | | | | --- | --- | -| **Status** | Planned | +| **Status** | Implemented | | **Anchor date** | 2026-09-25 | | **Primary PRs** | (fill in as they merge) | | **Related** | [042-project-workspaces](../042-project-workspaces/000-plan.md), [062-workspace-child-diff](../062-workspace-child-diff/000-plan.md), `docs/components/workspaces.md`, PR #602 (superseded reference) | diff --git a/docs-ai/072-workspace-editing/001-action.md b/docs-ai/072-workspace-editing/001-action.md new file mode 100644 index 00000000..86be1258 --- /dev/null +++ b/docs-ai/072-workspace-editing/001-action.md @@ -0,0 +1,89 @@ +# 072 — Workspace Editing: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-09-25 | Plan written after reading PR #602 and the entry-042 code; branch `feature/workspace-editing` from `main` (`869b5d9f`) | this entry | +| 2026-09-25 | Domain: `ProjectWorkspace.update`, `ProjectWorkspaceUpdateRequest` / `Member` / `Removal` / `Result`, description, task links, and role on creation, unknown-key-preserving `encodeMetadata`, minimum member count 1 | PR #TBD | +| 2026-09-25 | `WorkspaceCreationPromptFeature` → `WorkspaceEditorFeature` with `mode`, existing-member rows, staged removal, reorder, task links, description; `WorkspaceCreationPromptView` → `WorkspaceEditorView` | PR #TBD | +| 2026-09-25 | `RepositoriesFeature+WorkspaceEditing.swift` (`workspaceEditing` action family), entry points in sidebar, detail view, Settings, Command Palette, Worktrees menu | PR #TBD | +| 2026-09-25 | Tests, `docs/` manual updates, this record | PR #TBD | + +## Outcome & current state (as of 2026-09-25) + +- **Domain** — `supacode/Domain/ProjectWorkspace.swift` + - `ProjectWorkspaceCreationDraft` carries `description` and `taskLinks`; + `ProjectWorkspaceRepositoryPlan` / `ProjectWorkspaceCreationRepository` carry `role`. + `create` writes them and now accepts one repository (`notEnoughRepositories` reads + "Add at least one repository."). + - `ProjectWorkspace.update(_:fileManager:gitRunner:)` takes a + `ProjectWorkspaceUpdateRequest` (root, title, description, task links, ordered + `members` of `.existing(entry)` / `.added(plan)`, `removals`, `updatedAt`) and returns a + `ProjectWorkspaceUpdateResult` (saved workspace, `cleanupFailures`, + `completedRemovals`). Order: validate → materialize additions (ledger rollback on + failure, occupied names seeded with every current entry path) → write metadata → + best-effort cleanup of removals with `deleteFiles`. Cleanup deletes a symlink, a + remote clone folder, or runs `git worktree remove --force` against the recorded source; + paths outside the root and entries without a source are reported, never deleted. + Branch deletion is left to the caller through `completedRemovals`. + - `encodeMetadata(_:preservingUnknownKeysIn:)` merges the encoded model over the + existing JSON so unknown top-level and per-entry keys (matched by `id`, falling back + to `path` / `name`) survive; known keys always follow the model. +- **Editor reducer** — `supacode/Features/Repositories/Reducer/WorkspaceEditorFeature.swift` + - `State.mode` (`.create` / `.edit(repositoryID:)`), `existingRepositories` + (`WorkspaceEditorExistingRepository`: entry, editable name/role, `removal` with + `deleteFiles` / `deleteBranch`), `repositories` (new rows, unchanged), `description`, + `taskLinks` (`WorkspaceTaskLinkDraft`), `isSaving`. `init(editing:rootURL:repositoryID:...)` + pre-fills from a `ProjectWorkspace`. + - `submitButtonTapped` validates (title, ≥1 remaining member, new-row plans) and emits + `Delegate.submit(.create(draft))` or `.submit(.update(request))`; `updatedAt` comes + from `@Dependency(\.date.now)`. +- **Repositories wiring** — + `supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceEditing.swift` + - `WorkspaceEditingAction`: `promptRequested(id, removingChildID:)` re-reads the metadata + from disk in an effect and presents the editor (`promptLoaded`), optionally pre-marking + a child (sidebar row id = working-directory path); `saveWorkspace` runs + `ProjectWorkspace.update`, deletes opted-in branches through + `gitClient.deleteLocalBranch` (protected branches skipped), then `workspaceSaved` + reloads repositories, toasts "Workspace saved", and alerts on cleanup failures; + `workspaceSaveFailed` keeps the sheet open with the message (or alerts when the sheet + is already gone). Save is deliberately not cancellable. + - `RepositoriesFeature.State.workspaceEditor` replaces `workspaceCreationPrompt`; + `RepositoriesFeature+CoreReducer.swift` routes the editor delegate by mode. +- **Views** — `supacode/Features/Repositories/Views/WorkspaceEditorView.swift` (shared form; + existing rows with Name/Role, provenance line, Move Up/Down, trash → inline removal + options + Undo; new rows gain Role; Description and Task Links sections; Cancel disabled + while saving in edit mode), `WorkspaceDetailView.swift` (Edit Workspace… button, + clickable http(s) task links), `WorkspaceChildRowsView.swift` (Edit Workspace… / + Remove from Workspace…), `RepositorySectionView.swift` (header menu item), + `supacode/Features/Settings/Views/RepositorySettingsView.swift` (Edit Workspace… button + replacing the read-only note). +- **Other entry points** — `RepositorySettingsFeature.Delegate.editWorkspace` → + `SettingsFeature.Delegate.editWorkspace` → `AppFeature` surfaces the main window and + sends `promptRequested`; `CommandPaletteItem.Kind.editWorkspace` (offered when the active + repository is a workspace); `supacode/Commands/WorktreeCommands.swift` "Edit Workspace...". +- **Tests** — `supacodeTests/ProjectWorkspaceUpdateTests.swift`, + `supacodeTests/WorkspaceEditorFeatureTests.swift`, + `supacodeTests/RepositoriesFeatureWorkspaceEditingTests.swift`, + `supacodeTests/AppFeatureWorkspaceEditingTests.swift`; existing creation tests updated + for the rename and the new submission enum. +- **Docs** — `docs/components/workspaces.md` (Editing a workspace, metadata no longer + read-only, minimum count), `docs/components/command-palette.md`, + `docs/components/repositories-and-worktrees.md`, `docs/components/settings.md`. + +## Deviations from plan + +- The plan listed "settings-originated request through `AppFeature`" as a test; it exists, + and `AppFeature` uses `appLifecycleClient.surfaceMainWindow` rather than a new client. +- `.dismiss` on the sheet cannot be blocked while saving: TCA's `ifLet` clears the child + state before the reducer sees the action. The Cancel button is disabled instead, and a + failure arriving after the sheet is gone is shown as an alert. Not a behavior gap, but + the plan implied the reducer could refuse a dismissal. +- Unknown-key preservation also matches entries without an `id` (by `path`, then `name`), + which the plan did not spell out. + +## Open questions + +- Reordering is Move Up / Move Down buttons; drag reordering inside the sheet was not + attempted. Revisit if the list grows beyond a handful of members. diff --git a/docs/components/command-palette.md b/docs/components/command-palette.md index 13021080..2ed453bc 100644 --- a/docs/components/command-palette.md +++ b/docs/components/command-palette.md @@ -43,8 +43,10 @@ selected worktree has a pull request). Merge PR, Close PR. See [github-pull-requests](github-pull-requests.md). - **Terminal:** font size, find, tab/pane selection, new/close terminal (mirrors the Ghostty-bridged commands; search-only). -- **App:** Check for Updates, Open Settings, Open Repository, **Install Command - Line Tool**, Repo Settings. +- **App:** Check for Updates, Open Settings, Open Repository, New Workspace, + **Install Command Line Tool**, Repo Settings, and **Edit Workspace** while a + workspace (or one of its children) is selected — see + [workspaces](workspaces.md). - **Custom commands:** enabled local and Global Custom Commands appear here with their source. Same-titled commands can coexist; disabled commands do not appear. - **Agent profiles** (when a terminal worktree is selected): a diff --git a/docs/components/repositories-and-worktrees.md b/docs/components/repositories-and-worktrees.md index fe8f2415..cf61d209 100644 --- a/docs/components/repositories-and-worktrees.md +++ b/docs/components/repositories-and-worktrees.md @@ -228,9 +228,10 @@ and the confirmation names the target worktree). The repository **header** menu (right-click, or the **⋯** button) offers **New Worktree** (git repos only), **Repo Settings…**, and **Remove Repository**. Plain folders and workspaces get **Copy Path** / -**Reveal in Finder** instead of New Worktree; git repo headers deliberately have -no path actions because a repository's root can be a bare directory — use the -worktree rows for paths. +**Reveal in Finder** instead of New Worktree, and workspaces additionally get +**Edit Workspace…** (see [workspaces](workspaces.md)); git repo headers +deliberately have no path actions because a repository's root can be a bare +directory — use the worktree rows for paths. ## Repository appearance (icon & color) diff --git a/docs/components/settings.md b/docs/components/settings.md index c32e4cd7..18e69a99 100644 --- a/docs/components/settings.md +++ b/docs/components/settings.md @@ -39,7 +39,7 @@ and opens that section's root. | **Agents → Profiles** | Named launch presets for supported agent runtimes (model, effort, execution mode, tab/split placement, extra arguments, opt-in dedicated home for a separate account) with a live launch preview. List order is the recommendation fallback. → [agent-profiles](agent-profiles.md) | | **Agents → CLI & Skills** | Install/status for the bundled `prowl` CLI, the local socket path it uses to reach the app and whether this app is **listening** on it, and the **Agent Skills** section that links the bundled skills into your agents' skill folders. → [cli](cli.md) | | **Agents → Workflows** | Compact Built-in and personal workflow lists. Select a row for Enabled, explicit Run target, role profile preferences, Run Setup, validation, and source-file actions; **New Workflow…**, **Create with Agent…**, the **Run History** summary with **Clear History…**, and the CLI dependency banner remain on the index. → [workflows](workflows.md) | -| **Repositories / Repo Settings** | Per-repository: setup/archive/run scripts, **Custom Commands**, Global-command visibility, **Default Agent Profile**, a direct **Workflows** list, default base ref & directory, copy-files overrides, open-with app, custom title, icon & color, PR merge strategy, line-diff & PR-state fetching. Reached from the sidebar context menu → "Repo Settings". → [custom-actions](custom-actions.md), [repositories-and-worktrees](repositories-and-worktrees.md), [workflows](workflows.md) | +| **Repositories / Repo Settings** | Per-repository: setup/archive/run scripts, **Custom Commands**, Global-command visibility, **Default Agent Profile**, a direct **Workflows** list, default base ref & directory, copy-files overrides, open-with app, custom title, icon & color, PR merge strategy, line-diff & PR-state fetching. Reached from the sidebar context menu → "Repo Settings". A workspace's page also shows its metadata with an **Edit Workspace…** button that opens the editor on the main window. → [custom-actions](custom-actions.md), [repositories-and-worktrees](repositories-and-worktrees.md), [workflows](workflows.md), [workspaces](workspaces.md) | ## Where settings live on disk diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index cab3df17..91859983 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -23,7 +23,13 @@ Use **Add...** from the sidebar toolbar and choose **Add Workspace**, or use the Worktrees menu or command palette to create a workspace. Prowl creates the shared folder, materializes the selected repositories, writes `.prowl/workspace.json`, and opens the workspace as a runnable folder. A -workspace needs at least two repositories. +workspace needs at least one repository; more can be added later (see +[Editing a workspace](#editing-a-workspace)). + +Besides the title and folder, the creation sheet takes an optional +**Description**, a list of **Task Links** (URLs or issue keys, one per row), +and an optional **Role** per repository (`app`, `backend`, `docs`, …). All of +them land in `.prowl/workspace.json` so agents can read them. While a workspace is being created the prompt shows a spinner. **Cancel** stops the creation and rolls back everything created so far: cloned folders, created @@ -112,7 +118,9 @@ including immediately after a newly created workspace is opened. Click a child row to select it and focus its terminal tab rooted at that repository folder inside the workspace, creating that tab the first time it is selected. Right-click a child row for **Copy Path** / **Reveal in Finder** / -**Show Diff** / **Show Outgoing Changes**. +**Show Diff** / **Show Outgoing Changes** / **Edit Workspace…** / +**Remove from Workspace…** (opens the editor with that repository already +marked for removal). Diff works per child repository: click the child's `+N/-M` badge to open the diff for that repository, or, with a child selected, use `⌘⇧Y` (Show Diff), @@ -120,6 +128,59 @@ diff for that repository, or, with a child selected, use `⌘⇧Y` (Show Diff), configured diff tools apply; the Hunk tool opens a workspace terminal tab rooted at the child folder. See [diff-view](diff-view.md). +## Editing a workspace + +A workspace stays editable after creation. Open the editor from any of: + +- the sidebar: right-click the workspace row (or its `…` menu) → **Edit + Workspace…**, or right-click a child row → **Edit Workspace…** / **Remove + from Workspace…**; +- the workspace detail view (select the workspace row) → **Edit Workspace…**; +- Settings → the workspace under Repositories → **Edit Workspace…** (the editor + opens as a sheet on the main window, which is brought to the front); +- the Command Palette → **Edit Workspace** while a workspace or one of its + children is selected; +- the Worktrees menu → **Edit Workspace...** (enabled when a workspace is + selected). + +The editor is the same sheet as creation, pre-filled from +`.prowl/workspace.json` (re-read from disk when it opens, so edits made +outside Prowl are respected). It lets you change: + +- **Title**, **Description**, and **Task Links**. The title is display-only; + the folder on disk keeps its name. +- **Name** and **Role** of every existing repository. Source, path, and + checkout are shown as read-only provenance; to change them, remove the + repository and add it again. +- **Order**: move existing repositories up or down. The sidebar and the + metadata follow the new order. +- **Added repositories**: the same **Add Opened** / **Add Remote** / **Add + Local** sources and Link / Create Branch / Use Existing checkouts as + creation. They are materialized when you save. +- **Removed repositories**: the trash button marks a repository for removal + (**Undo** restores it). By default only the metadata entry goes away and the + folder stays. Tick **Also delete the folder inside the workspace** to remove + what Prowl materialized: the symlink for a linked repository (the source is + untouched), `git worktree remove --force` plus the folder for a worktree, or + the cloned folder for a remote. Worktree entries with a recorded branch + additionally offer **Delete branch … in the source repository**, which goes + through the same protected-branch guard as the rest of Prowl. A workspace + must keep at least one repository. + +Nothing changes until you press **Save**. Save runs in this order so the +workspace is never left half-changed: added repositories are materialized +first (and rolled back if one fails, leaving the old metadata untouched), then +`.prowl/workspace.json` is rewritten, then removed repositories are cleaned +up. Cleanup is best-effort: if a worktree cannot be unregistered, or a +repository path lies outside the workspace folder, the entry is still removed +from the metadata and Prowl shows an alert listing what was left on disk. Save +cannot be canceled once it has started. After a successful save the sidebar +reloads and a **Workspace saved** toast appears. + +Paths inside the workspace are kept unique: adding a repository whose folder +name is still occupied (for example by a member you are removing without +deleting its folder) gets a `-2` suffix instead of failing. + ## Removing a workspace **Remove Repository** on a workspace opens a dedicated confirmation. By default @@ -141,8 +202,11 @@ anyway would leave a dangling worktree registration in the source repository. ## Metadata The workspace's repository settings page (Settings → the workspace under -Repositories) shows this metadata read-only; edit `.prowl/workspace.json` to -change it. +Repositories) shows this metadata and offers **Edit Workspace…**, which opens +the editor described above. `.prowl/workspace.json` remains the source of +truth: hand edits are picked up on the next reload, and when Prowl saves it +keeps top-level and per-repository keys it does not know about, so fields +written by other tools survive. Example `.prowl/workspace.json`: -- 2.51.2 From 39e8cb434a228effdf758a0ee4c719539c3270d7 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 25 Sep 2026 17:08:10 +0800 Subject: [PATCH 06/15] Scroll the workspace editor to the invalid row from the outer scroll view --- .../Views/WorkspaceEditorView.swift | 79 +++++++++---------- 1 file changed, 39 insertions(+), 40 deletions(-) diff --git a/supacode/Features/Repositories/Views/WorkspaceEditorView.swift b/supacode/Features/Repositories/Views/WorkspaceEditorView.swift index e882dc5e..6bcfdb5a 100644 --- a/supacode/Features/Repositories/Views/WorkspaceEditorView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceEditorView.swift @@ -12,17 +12,29 @@ struct WorkspaceEditorView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { headerSection - ScrollView { - VStack(alignment: .leading, spacing: 16) { - titleSection - if !store.mode.isEditing { - folderSection + // The reader wraps the scroll view so a validation error can bring the + // offending repository row into view. + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 16) { + titleSection + if !store.mode.isEditing { + folderSection + } + descriptionSection + taskLinksSection + repositoriesSection + } + .padding(.trailing, 4) + } + .onChange(of: store.validationRequestID) { _, _ in + guard let repositoryID = validationRepositoryID else { + return + } + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(repositoryID, anchor: .center) } - descriptionSection - taskLinksSection - repositoriesSection } - .padding(.trailing, 4) } .frame(maxHeight: 560) @@ -236,35 +248,25 @@ struct WorkspaceEditorView: View { .disabled(store.isSaving) } helpText(repositoriesHelpText) - ScrollViewReader { proxy in - VStack(spacing: 0) { - ForEach(store.existingRepositories) { repository in - existingRepositoryEditor(repository) - .id(repository.id) + VStack(spacing: 0) { + ForEach(store.existingRepositories) { repository in + existingRepositoryEditor(repository) + .id(repository.id) + Divider() + } + ForEach(store.repositories) { repository in + repositoryEditor(repository) + .id(repository.id) + if repository.id != store.repositories.last?.id { Divider() } - ForEach(store.repositories) { repository in - repositoryEditor(repository) - .id(repository.id) - if repository.id != store.repositories.last?.id { - Divider() - } - } - if store.existingRepositories.isEmpty, store.repositories.isEmpty { - Text("No repositories yet. Add one from the buttons above.") - .font(.callout) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 24) - } } - .onChange(of: store.validationRequestID) { _, _ in - guard let repositoryID = validationRepositoryID else { - return - } - withAnimation(.easeInOut(duration: 0.2)) { - proxy.scrollTo(repositoryID, anchor: .center) - } + if store.existingRepositories.isEmpty, store.repositories.isEmpty { + Text("No repositories yet. Add one from the buttons above.") + .font(.callout) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 24) } } .clipShape(.rect(cornerRadius: 8)) @@ -483,7 +485,8 @@ struct WorkspaceEditorView: View { .help("Delete the branch with git branch -D after the worktree is removed. Protected branches are kept.") .disabled(store.isSaving || !removal.deleteFiles) } - helpText(removalHelp(repository.entry)) + helpText( + "The entry leaves the workspace metadata when you save. Files stay on disk unless you delete them here.") } } @@ -527,10 +530,6 @@ struct WorkspaceEditorView: View { } } - private func removalHelp(_ entry: ProjectWorkspaceRepositoryEntry) -> LocalizedStringKey { - "The entry leaves the workspace metadata when you save. Files stay on disk unless you delete them here." - } - // MARK: - New member rows private func repositoryEditor(_ repository: ProjectWorkspaceCreationRepository) -> some View { -- 2.51.2 From cc667f8f4196d78edbf9b515b623806abdb633f3 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 25 Sep 2026 17:09:44 +0800 Subject: [PATCH 07/15] Record PR #830 in docs-ai 072 --- docs-ai/072-workspace-editing/000-plan.md | 2 +- docs-ai/072-workspace-editing/001-action.md | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs-ai/072-workspace-editing/000-plan.md b/docs-ai/072-workspace-editing/000-plan.md index fcd0ca48..903ac124 100644 --- a/docs-ai/072-workspace-editing/000-plan.md +++ b/docs-ai/072-workspace-editing/000-plan.md @@ -4,7 +4,7 @@ | --- | --- | | **Status** | Implemented | | **Anchor date** | 2026-09-25 | -| **Primary PRs** | (fill in as they merge) | +| **Primary PRs** | #830 | | **Related** | [042-project-workspaces](../042-project-workspaces/000-plan.md), [062-workspace-child-diff](../062-workspace-child-diff/000-plan.md), `docs/components/workspaces.md`, PR #602 (superseded reference) | ## Background diff --git a/docs-ai/072-workspace-editing/001-action.md b/docs-ai/072-workspace-editing/001-action.md index 86be1258..5a3a5390 100644 --- a/docs-ai/072-workspace-editing/001-action.md +++ b/docs-ai/072-workspace-editing/001-action.md @@ -5,10 +5,10 @@ | Date | Change | Ref | | --- | --- | --- | | 2026-09-25 | Plan written after reading PR #602 and the entry-042 code; branch `feature/workspace-editing` from `main` (`869b5d9f`) | this entry | -| 2026-09-25 | Domain: `ProjectWorkspace.update`, `ProjectWorkspaceUpdateRequest` / `Member` / `Removal` / `Result`, description, task links, and role on creation, unknown-key-preserving `encodeMetadata`, minimum member count 1 | PR #TBD | -| 2026-09-25 | `WorkspaceCreationPromptFeature` → `WorkspaceEditorFeature` with `mode`, existing-member rows, staged removal, reorder, task links, description; `WorkspaceCreationPromptView` → `WorkspaceEditorView` | PR #TBD | -| 2026-09-25 | `RepositoriesFeature+WorkspaceEditing.swift` (`workspaceEditing` action family), entry points in sidebar, detail view, Settings, Command Palette, Worktrees menu | PR #TBD | -| 2026-09-25 | Tests, `docs/` manual updates, this record | PR #TBD | +| 2026-09-25 | Domain: `ProjectWorkspace.update`, `ProjectWorkspaceUpdateRequest` / `Member` / `Removal` / `Result`, description, task links, and role on creation, unknown-key-preserving `encodeMetadata`, minimum member count 1 | PR #830 | +| 2026-09-25 | `WorkspaceCreationPromptFeature` → `WorkspaceEditorFeature` with `mode`, existing-member rows, staged removal, reorder, task links, description; `WorkspaceCreationPromptView` → `WorkspaceEditorView` | PR #830 | +| 2026-09-25 | `RepositoriesFeature+WorkspaceEditing.swift` (`workspaceEditing` action family), entry points in sidebar, detail view, Settings, Command Palette, Worktrees menu | PR #830 | +| 2026-09-25 | Tests, `docs/` manual updates, this record | PR #830 | ## Outcome & current state (as of 2026-09-25) -- 2.51.2 From d8e601d58b0dff6a861724dedf348d9a73bce8e2 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 25 Sep 2026 17:25:03 +0800 Subject: [PATCH 08/15] Address review round 1: source-less cleanup, window surfacing order, interleaved member order - Never delete a member folder without a recorded source, whatever its source kind claims; the removal is reported instead (regression test with a sentinel file). - Surface the main window synchronously before dispatching the Settings- originated edit request, so the sheet cannot attach to a hidden window. - Keep one member order across existing and added rows so a repository added in the same edit can be moved anywhere; both submissions follow it. --- docs-ai/072-workspace-editing/001-action.md | 7 +- docs/components/workspaces.md | 12 +- supacode/Domain/ProjectWorkspace.swift | 14 ++- .../Features/App/Reducer/AppFeature.swift | 7 +- .../Reducer/WorkspaceEditorFeature.swift | 103 +++++++++++++++--- .../Views/WorkspaceEditorView.swift | 69 +++++++----- .../AppFeatureWorkspaceEditingTests.swift | 4 +- .../ProjectWorkspaceUpdateTests.swift | 42 +++++++ .../WorkspaceEditorFeatureTests.swift | 96 ++++++++++++++-- 9 files changed, 282 insertions(+), 72 deletions(-) diff --git a/docs-ai/072-workspace-editing/001-action.md b/docs-ai/072-workspace-editing/001-action.md index 5a3a5390..7283662d 100644 --- a/docs-ai/072-workspace-editing/001-action.md +++ b/docs-ai/072-workspace-editing/001-action.md @@ -9,6 +9,7 @@ | 2026-09-25 | `WorkspaceCreationPromptFeature` → `WorkspaceEditorFeature` with `mode`, existing-member rows, staged removal, reorder, task links, description; `WorkspaceCreationPromptView` → `WorkspaceEditorView` | PR #830 | | 2026-09-25 | `RepositoriesFeature+WorkspaceEditing.swift` (`workspaceEditing` action family), entry points in sidebar, detail view, Settings, Command Palette, Worktrees menu | PR #830 | | 2026-09-25 | Tests, `docs/` manual updates, this record | PR #830 | +| 2026-09-25 | Review Loop round 1 (Pi Reviewer): never delete a remote entry without a recorded source; surface the main window synchronously before the Settings-originated request; one interleaved member order so added rows can be moved among existing ones | PR #830 | ## Outcome & current state (as of 2026-09-25) @@ -25,7 +26,8 @@ failure, occupied names seeded with every current entry path) → write metadata → best-effort cleanup of removals with `deleteFiles`. Cleanup deletes a symlink, a remote clone folder, or runs `git worktree remove --force` against the recorded source; - paths outside the root and entries without a source are reported, never deleted. + paths outside the root and entries without a recorded source (whatever their kind) are + reported, never deleted. Branch deletion is left to the caller through `completedRemovals`. - `encodeMetadata(_:preservingUnknownKeysIn:)` merges the encoded model over the existing JSON so unknown top-level and per-entry keys (matched by `id`, falling back @@ -36,6 +38,9 @@ `deleteFiles` / `deleteBranch`), `repositories` (new rows, unchanged), `description`, `taskLinks` (`WorkspaceTaskLinkDraft`), `isSaving`. `init(editing:rootURL:repositoryID:...)` pre-fills from a `ProjectWorkspace`. + - `memberOrder` / `orderedMemberKeys` hold one order across existing and added rows + (`WorkspaceEditorMemberKey`); `memberMovedUp` / `memberMovedDown` work on any row and + the submission follows that order. - `submitButtonTapped` validates (title, ≥1 remaining member, new-row plans) and emits `Delegate.submit(.create(draft))` or `.submit(.update(request))`; `updatedAt` comes from `@Dependency(\.date.now)`. diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index 91859983..bbb96613 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -152,8 +152,8 @@ outside Prowl are respected). It lets you change: - **Name** and **Role** of every existing repository. Source, path, and checkout are shown as read-only provenance; to change them, remove the repository and add it again. -- **Order**: move existing repositories up or down. The sidebar and the - metadata follow the new order. +- **Order**: move any repository up or down, including ones added in the + same edit. The sidebar and the metadata follow the new order. - **Added repositories**: the same **Add Opened** / **Add Remote** / **Add Local** sources and Link / Create Branch / Use Existing checkouts as creation. They are materialized when you save. @@ -171,9 +171,11 @@ Nothing changes until you press **Save**. Save runs in this order so the workspace is never left half-changed: added repositories are materialized first (and rolled back if one fails, leaving the old metadata untouched), then `.prowl/workspace.json` is rewritten, then removed repositories are cleaned -up. Cleanup is best-effort: if a worktree cannot be unregistered, or a -repository path lies outside the workspace folder, the entry is still removed -from the metadata and Prowl shows an alert listing what was left on disk. Save +up. Cleanup is best-effort: if a worktree cannot be unregistered, a +repository path lies outside the workspace folder, or the entry has no recorded +source (so Prowl cannot tell whether it created the folder), the entry is still +removed from the metadata and Prowl shows an alert listing what was left on +disk. Save cannot be canceled once it has started. After a successful save the sidebar reloads and a **Workspace saved** toast appears. diff --git a/supacode/Domain/ProjectWorkspace.swift b/supacode/Domain/ProjectWorkspace.swift index 3cf7400f..3857f8d9 100644 --- a/supacode/Domain/ProjectWorkspace.swift +++ b/supacode/Domain/ProjectWorkspace.swift @@ -801,15 +801,21 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { return nil } do { - if isSymbolicLink || entry.sourceKind == .remote { - // A symlink removal never touches the linked checkout; a clone is - // wholly owned by the workspace. + if isSymbolicLink { + // A symlink removal never touches the linked checkout. try fileManager.removeItem(at: entryURL) return nil } - guard let sourceLocation = entry.sourceLocation else { + // Without a recorded source Prowl cannot tell whether it created the + // folder, so it is never deleted, whatever the source kind claims. + guard let sourceLocation = entry.sourceLocation, !sourceLocation.isEmpty else { return String(localized: "\(entryPath) was not created by Prowl and was left in place.") } + if entry.sourceKind == .remote { + // A clone is wholly owned by the workspace. + try fileManager.removeItem(at: entryURL) + return nil + } try await gitRunner.run( ProjectWorkspaceGitCommand( arguments: ["-C", sourceLocation, "worktree", "remove", "--force", entryPath], diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 724e17c4..c0c689eb 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -584,10 +584,9 @@ struct AppFeature { } // The editor is a sheet on the main window; bring it forward from // the Settings window first so the sheet is not opened out of sight. - return .merge( - .run { _ in _ = await appLifecycleClient.surfaceMainWindow() }, - .send(.repositories(.workspaceEditing(.promptRequested(repositoryID, removingChildID: nil)))) - ) + // Surfacing is synchronous so it strictly precedes the request. + _ = appLifecycleClient.surfaceMainWindow() + return .send(.repositories(.workspaceEditing(.promptRequested(repositoryID, removingChildID: nil)))) case .settings(.delegate(.cliInstallCompleted(let result))): switch result { diff --git a/supacode/Features/Repositories/Reducer/WorkspaceEditorFeature.swift b/supacode/Features/Repositories/Reducer/WorkspaceEditorFeature.swift index 2a2d4a89..f1e71200 100644 --- a/supacode/Features/Repositories/Reducer/WorkspaceEditorFeature.swift +++ b/supacode/Features/Repositories/Reducer/WorkspaceEditorFeature.swift @@ -53,6 +53,27 @@ enum WorkspaceEditorSubmission: Equatable, Sendable { case update(ProjectWorkspaceUpdateRequest) } +/// Identifies one row of the editor across both member collections so a +/// single order can interleave existing and added members. +enum WorkspaceEditorMemberKey: Equatable, Hashable, Sendable { + case existing(String) + case added(Repository.ID) +} + +enum WorkspaceEditorMember: Equatable, Identifiable { + case existing(WorkspaceEditorExistingRepository) + case added(ProjectWorkspaceCreationRepository) + + var id: WorkspaceEditorMemberKey { + switch self { + case .existing(let repository): + return .existing(repository.id) + case .added(let repository): + return .added(repository.id) + } + } +} + @Reducer struct WorkspaceEditorFeature { private enum CancelID { @@ -87,6 +108,10 @@ struct WorkspaceEditorFeature { var validationRequestID = 0 var isSaving = false var remoteRepositoryPrompt: RemoteRepositoryPromptState? + /// Explicit member order, changed only by move actions. Keys for rows + /// that were added since the last move are appended by `orderedMemberKeys`, + /// existing members first, so add/remove never have to touch it. + var memberOrder: [WorkspaceEditorMemberKey] = [] var availableOpenedRepositories: [ProjectWorkspaceCreationRepository] { openedRepositoryCandidates.filter { repositories[id: $0.id] == nil } @@ -105,6 +130,34 @@ struct WorkspaceEditorFeature { existingRepositories.contains(where: \.isMarkedForRemoval) } + /// `memberOrder` reconciled with the rows that currently exist: stale + /// keys drop out, new rows append (existing members before added ones). + var orderedMemberKeys: [WorkspaceEditorMemberKey] { + let present = + Set(existingRepositories.ids.map(WorkspaceEditorMemberKey.existing)) + .union(repositories.ids.map(WorkspaceEditorMemberKey.added)) + var keys = memberOrder.filter { present.contains($0) } + var seen = Set(keys) + for id in existingRepositories.ids where seen.insert(.existing(id)).inserted { + keys.append(.existing(id)) + } + for id in repositories.ids where seen.insert(.added(id)).inserted { + keys.append(.added(id)) + } + return keys + } + + var orderedMembers: [WorkspaceEditorMember] { + orderedMemberKeys.compactMap { key in + switch key { + case .existing(let id): + return existingRepositories[id: id].map(WorkspaceEditorMember.existing) + case .added(let id): + return repositories[id: id].map(WorkspaceEditorMember.added) + } + } + } + init( repositories: [ProjectWorkspaceCreationRepository], title: String, @@ -234,8 +287,8 @@ struct WorkspaceEditorFeature { case existingRepositoryRemovalUndone(String) case existingRepositoryDeleteFilesChanged(String, Bool) case existingRepositoryDeleteBranchChanged(String, Bool) - case existingRepositoryMovedUp(String) - case existingRepositoryMovedDown(String) + case memberMovedUp(WorkspaceEditorMemberKey) + case memberMovedDown(WorkspaceEditorMemberKey) case rootPathChosen(String) case cancelButtonTapped case submitButtonTapped @@ -609,20 +662,22 @@ struct WorkspaceEditorFeature { state.existingRepositories[id: entryID]?.removal = removal return .none - case .existingRepositoryMovedUp(let entryID): - guard let index = state.existingRepositories.index(id: entryID), index > 0 else { + case .memberMovedUp(let key): + var keys = state.orderedMemberKeys + guard let index = keys.firstIndex(of: key), index > 0 else { return .none } - state.existingRepositories.swapAt(index, index - 1) + keys.swapAt(index, index - 1) + state.memberOrder = keys return .none - case .existingRepositoryMovedDown(let entryID): - guard let index = state.existingRepositories.index(id: entryID), - index < state.existingRepositories.count - 1 - else { + case .memberMovedDown(let key): + var keys = state.orderedMemberKeys + guard let index = keys.firstIndex(of: key), index < keys.count - 1 else { return .none } - state.existingRepositories.swapAt(index, index + 1) + keys.swapAt(index, index + 1) + state.memberOrder = keys return .none case .rootPathChosen(let path): @@ -658,11 +713,11 @@ struct WorkspaceEditorFeature { ) return .none } - var plans: [ProjectWorkspaceRepositoryPlan] = [] + var plansByID: [Repository.ID: ProjectWorkspaceRepositoryPlan] = [:] for repository in state.repositories { switch Self.plan(for: repository) { case .success(let plan): - plans.append(plan) + plansByID[repository.id] = plan case .failure(let error): state.setValidation( error.localizedDescription, @@ -672,6 +727,13 @@ struct WorkspaceEditorFeature { } } state.clearValidation() + // Both submissions follow the user's row order, interleaving existing + // and added members. + let orderedKeys = state.orderedMemberKeys + let plans = orderedKeys.compactMap { key -> ProjectWorkspaceRepositoryPlan? in + guard case .added(let id) = key else { return nil } + return plansByID[id] + } let description = state.description.trimmingCharacters(in: .whitespacesAndNewlines) let taskLinks = state.taskLinks.map { $0.value.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } @@ -693,11 +755,18 @@ struct WorkspaceEditorFeature { ) ) case .edit: - let members: [ProjectWorkspaceUpdateMember] = - state.existingRepositories - .filter { !$0.isMarkedForRemoval } - .map { .existing($0.editedEntry) } - + plans.map { .added($0) } + let members: [ProjectWorkspaceUpdateMember] = orderedKeys.compactMap { key in + switch key { + case .existing(let id): + guard let repository = state.existingRepositories[id: id], !repository.isMarkedForRemoval + else { + return nil + } + return .existing(repository.editedEntry) + case .added(let id): + return plansByID[id].map { .added($0) } + } + } let removals = state.existingRepositories.compactMap { repository in repository.removal.map { removal in ProjectWorkspaceRepositoryRemoval( diff --git a/supacode/Features/Repositories/Views/WorkspaceEditorView.swift b/supacode/Features/Repositories/Views/WorkspaceEditorView.swift index 6bcfdb5a..f21a3b2f 100644 --- a/supacode/Features/Repositories/Views/WorkspaceEditorView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceEditorView.swift @@ -248,20 +248,22 @@ struct WorkspaceEditorView: View { .disabled(store.isSaving) } helpText(repositoriesHelpText) + let members = store.orderedMembers VStack(spacing: 0) { - ForEach(store.existingRepositories) { repository in - existingRepositoryEditor(repository) - .id(repository.id) - Divider() - } - ForEach(store.repositories) { repository in - repositoryEditor(repository) - .id(repository.id) - if repository.id != store.repositories.last?.id { + ForEach(members) { member in + switch member { + case .existing(let repository): + existingRepositoryEditor(repository) + .id(repository.id) + case .added(let repository): + repositoryEditor(repository) + .id(repository.id) + } + if member.id != members.last?.id { Divider() } } - if store.existingRepositories.isEmpty, store.repositories.isEmpty { + if members.isEmpty { Text("No repositories yet. Add one from the buttons above.") .font(.callout) .foregroundStyle(.secondary) @@ -367,24 +369,7 @@ struct WorkspaceEditorView: View { .foregroundStyle(.red) } Spacer() - Button { - store.send(.existingRepositoryMovedUp(repository.id)) - } label: { - Image(systemName: "chevron.up") - .accessibilityLabel("Move Up") - } - .buttonStyle(.borderless) - .help("Move Up") - .disabled(store.isSaving || store.existingRepositories.first?.id == repository.id) - Button { - store.send(.existingRepositoryMovedDown(repository.id)) - } label: { - Image(systemName: "chevron.down") - .accessibilityLabel("Move Down") - } - .buttonStyle(.borderless) - .help("Move Down") - .disabled(store.isSaving || store.existingRepositories.last?.id == repository.id) + moveButtons(for: .existing(repository.id)) if repository.isMarkedForRemoval { Button("Undo") { store.send(.existingRepositoryRemovalUndone(repository.id)) @@ -406,6 +391,32 @@ struct WorkspaceEditorView: View { } } + /// Move Up / Move Down for any row; the order interleaves existing and + /// added members so a new repository can go anywhere in the list. + private func moveButtons(for key: WorkspaceEditorMemberKey) -> some View { + let keys = store.orderedMemberKeys + return HStack(spacing: 4) { + Button { + store.send(.memberMovedUp(key)) + } label: { + Image(systemName: "chevron.up") + .accessibilityLabel("Move Up") + } + .buttonStyle(.borderless) + .help("Move Up") + .disabled(store.isSaving || keys.first == key) + Button { + store.send(.memberMovedDown(key)) + } label: { + Image(systemName: "chevron.down") + .accessibilityLabel("Move Down") + } + .buttonStyle(.borderless) + .help("Move Down") + .disabled(store.isSaving || keys.last == key) + } + } + private func existingRepositoryFields(_ repository: WorkspaceEditorExistingRepository) -> some View { HStack(spacing: 8) { VStack(alignment: .leading, spacing: 4) { @@ -559,6 +570,8 @@ struct WorkspaceEditorView: View { Spacer() + moveButtons(for: .added(repository.id)) + Button { store.send(.removeRepository(repository.id)) } label: { diff --git a/supacodeTests/AppFeatureWorkspaceEditingTests.swift b/supacodeTests/AppFeatureWorkspaceEditingTests.swift index 70f4719c..bbd57e79 100644 --- a/supacodeTests/AppFeatureWorkspaceEditingTests.swift +++ b/supacodeTests/AppFeatureWorkspaceEditingTests.swift @@ -38,9 +38,11 @@ struct AppFeatureWorkspaceEditingTests { store.exhaustivity = .off await store.send(.settings(.delegate(.editWorkspace("/tmp/ws")))) + // Surfacing must already have happened when the request is dispatched, + // so the sheet never attaches to a hidden or minimized main window. + #expect(surfaced.value) await store.receive(\.repositories.workspaceEditing.promptRequested) await store.finish() - #expect(surfaced.value) } @Test(.dependencies) func settingsEditWorkspaceIgnoresNonWorkspaceRepositories() async { diff --git a/supacodeTests/ProjectWorkspaceUpdateTests.swift b/supacodeTests/ProjectWorkspaceUpdateTests.swift index 450fc31f..4a7dc6ad 100644 --- a/supacodeTests/ProjectWorkspaceUpdateTests.swift +++ b/supacodeTests/ProjectWorkspaceUpdateTests.swift @@ -412,6 +412,48 @@ struct ProjectWorkspaceUpdateTests { #expect(result.completedRemovals.map(\.id) == ["api"]) } + @Test func updateNeverDeletesRemoteEntryWithoutRecordedSource() async throws { + let rootURL = try makeTemporaryRoot(prefix: "prowl-update-sourceless") + defer { try? FileManager.default.removeItem(at: rootURL) } + let folderURL = rootURL.appending(path: "api", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + let sentinelURL = folderURL.appending(path: "notes.txt") + try Data("keep".utf8).write(to: sentinelURL) + try writeWorkspaceJSON( + """ + { + "title": "Sourceless", + "repositories": [ + { "id": "app", "name": "App", "path": "app", "source_kind": "existing_path" }, + { "id": "api", "name": "API", "path": "api", "source_kind": "remote" } + ] + } + """, + to: rootURL + ) + let existing = try #require(ProjectWorkspace.load(from: rootURL)) + let appEntry = try #require(existing.repositories.first { $0.id == "app" }) + let apiEntry = try #require(existing.repositories.first { $0.id == "api" }) + #expect(apiEntry.sourceLocation == nil) + + let result = try await ProjectWorkspace.update( + ProjectWorkspaceUpdateRequest( + rootURL: rootURL, + title: "Sourceless", + members: [.existing(appEntry)], + removals: [ProjectWorkspaceRepositoryRemoval(entry: apiEntry, deleteFiles: true)], + updatedAt: Date() + ), + gitRunner: failingGitRunner + ) + + // Ownership cannot be established, so the folder stays and is reported. + #expect(result.cleanupFailures.map(\.entryID) == ["api"]) + #expect(result.completedRemovals.isEmpty) + #expect(FileManager.default.fileExists(atPath: sentinelURL.path(percentEncoded: false))) + #expect(ProjectWorkspace.load(from: rootURL)?.repositories.map(\.id) == ["app"]) + } + @Test func updateRejectsInvalidRequests() async throws { let rootURL = try makeTemporaryRoot(prefix: "prowl-update-invalid") let appURL = try makeTemporaryRoot(prefix: "prowl-app") diff --git a/supacodeTests/WorkspaceEditorFeatureTests.swift b/supacodeTests/WorkspaceEditorFeatureTests.swift index e028148e..0c5a8c01 100644 --- a/supacodeTests/WorkspaceEditorFeatureTests.swift +++ b/supacodeTests/WorkspaceEditorFeatureTests.swift @@ -148,20 +148,92 @@ struct WorkspaceEditorFeatureTests { WorkspaceEditorFeature() } - await store.send(.existingRepositoryMovedUp("app")) - await store.send(.existingRepositoryMovedDown("app")) { - $0.existingRepositories = [ - WorkspaceEditorExistingRepository(entry: self.apiEntry), - WorkspaceEditorExistingRepository(entry: self.appEntry), - ] + #expect(store.state.orderedMemberKeys == [.existing("app"), .existing("api")]) + await store.send(.memberMovedUp(.existing("app"))) + await store.send(.memberMovedDown(.existing("app"))) { + $0.memberOrder = [.existing("api"), .existing("app")] } - await store.send(.existingRepositoryMovedDown("app")) - await store.send(.existingRepositoryMovedUp("app")) { - $0.existingRepositories = [ - WorkspaceEditorExistingRepository(entry: self.appEntry), - WorkspaceEditorExistingRepository(entry: self.apiEntry), - ] + await store.send(.memberMovedDown(.existing("app"))) + await store.send(.memberMovedUp(.existing("app"))) { + $0.memberOrder = [.existing("app"), .existing("api")] + } + #expect(store.state.orderedMembers.map(\.id) == [.existing("app"), .existing("api")]) + } + + @Test func addedMemberCanMoveBeforeExistingMembersAndSubmitKeepsThatOrder() async { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let shared = ProjectWorkspaceCreationRepository( + id: "/tmp/source/shared", + name: "Shared", + rootURL: URL(fileURLWithPath: "/tmp/source/shared") + ) + let store = TestStore(initialState: makeEditState()) { + WorkspaceEditorFeature() + } withDependencies: { + $0.date.now = now + } + + await store.send(.addOpenedRepository("/tmp/source/shared")) { + $0.repositories = [shared] + } + await store.receive(\.delegate.baseRefSourceChanged) + // Added rows append after existing members until they are moved. + #expect( + store.state.orderedMemberKeys == [.existing("app"), .existing("api"), .added("/tmp/source/shared")]) + await store.send(.memberMovedUp(.added("/tmp/source/shared"))) { + $0.memberOrder = [.existing("app"), .added("/tmp/source/shared"), .existing("api")] + } + await store.send(.memberMovedUp(.added("/tmp/source/shared"))) { + $0.memberOrder = [.added("/tmp/source/shared"), .existing("app"), .existing("api")] + } + await store.send(.memberMovedUp(.added("/tmp/source/shared"))) + + await store.send(.submitButtonTapped) + await store.receive( + .delegate( + .submit( + .update( + ProjectWorkspaceUpdateRequest( + rootURL: rootURL, + title: "Checkout Flow", + description: "Ship the flow", + taskLinks: ["https://example.com/issues/1"], + members: [ + .added( + ProjectWorkspaceRepositoryPlan( + id: "/tmp/source/shared", + name: "Shared", + path: nil, + sourceKind: .existingPath, + sourceLocation: "/tmp/source/shared", + checkout: .link + )), + .existing(appEntry), + .existing(apiEntry), + ], + updatedAt: now + ) + ) + ) + ) + ) + } + + @Test func removedRowsDropOutOfTheOrder() async { + var state = makeEditState() + state.repositories = [ + ProjectWorkspaceCreationRepository( + id: "/tmp/source/shared", name: "Shared", rootURL: URL(fileURLWithPath: "/tmp/source/shared")) + ] + state.memberOrder = [.added("/tmp/source/shared"), .existing("app"), .existing("api")] + let store = TestStore(initialState: state) { + WorkspaceEditorFeature() + } + + await store.send(.removeRepository("/tmp/source/shared")) { + $0.repositories = [] } + #expect(store.state.orderedMemberKeys == [.existing("app"), .existing("api")]) } @Test func submitRefusesToRemoveTheLastMember() async { -- 2.51.2 From 7cd4d64a7b9d0791ef552b058586cd8e7c4ab97c Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 25 Sep 2026 17:34:24 +0800 Subject: [PATCH 09/15] Report a refused branch deletion after a committed workspace save The save is already committed when the opted-in branch deletion runs, so a thrown git error is appended to the cleanup failures shown in the alert instead of being swallowed behind the success toast. --- docs-ai/072-workspace-editing/001-action.md | 4 +- docs/components/workspaces.md | 6 +- ...RepositoriesFeature+WorkspaceEditing.swift | 37 +++++++---- ...sitoriesFeatureWorkspaceEditingTests.swift | 63 +++++++++++++++++++ 4 files changed, 94 insertions(+), 16 deletions(-) diff --git a/docs-ai/072-workspace-editing/001-action.md b/docs-ai/072-workspace-editing/001-action.md index 7283662d..f4394b22 100644 --- a/docs-ai/072-workspace-editing/001-action.md +++ b/docs-ai/072-workspace-editing/001-action.md @@ -9,6 +9,7 @@ | 2026-09-25 | `WorkspaceCreationPromptFeature` → `WorkspaceEditorFeature` with `mode`, existing-member rows, staged removal, reorder, task links, description; `WorkspaceCreationPromptView` → `WorkspaceEditorView` | PR #830 | | 2026-09-25 | `RepositoriesFeature+WorkspaceEditing.swift` (`workspaceEditing` action family), entry points in sidebar, detail view, Settings, Command Palette, Worktrees menu | PR #830 | | 2026-09-25 | Tests, `docs/` manual updates, this record | PR #830 | +| 2026-09-25 | Review Loop round 2 (Pi Reviewer): a refused branch deletion after a committed save is reported in the cleanup alert instead of vanishing behind the success toast | PR #830 | | 2026-09-25 | Review Loop round 1 (Pi Reviewer): never delete a remote entry without a recorded source; surface the main window synchronously before the Settings-originated request; one interleaved member order so added rows can be moved among existing ones | PR #830 | ## Outcome & current state (as of 2026-09-25) @@ -50,7 +51,8 @@ from disk in an effect and presents the editor (`promptLoaded`), optionally pre-marking a child (sidebar row id = working-directory path); `saveWorkspace` runs `ProjectWorkspace.update`, deletes opted-in branches through - `gitClient.deleteLocalBranch` (protected branches skipped), then `workspaceSaved` + `gitClient.deleteLocalBranch` (protected branches skipped, a thrown deletion error is + appended to the cleanup failures), then `workspaceSaved` reloads repositories, toasts "Workspace saved", and alerts on cleanup failures; `workspaceSaveFailed` keeps the sheet open with the message (or alerts when the sheet is already gone). Save is deliberately not cancellable. diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index bbb96613..dcabc9a7 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -173,9 +173,9 @@ first (and rolled back if one fails, leaving the old metadata untouched), then `.prowl/workspace.json` is rewritten, then removed repositories are cleaned up. Cleanup is best-effort: if a worktree cannot be unregistered, a repository path lies outside the workspace folder, or the entry has no recorded -source (so Prowl cannot tell whether it created the folder), the entry is still -removed from the metadata and Prowl shows an alert listing what was left on -disk. Save +source (so Prowl cannot tell whether it created the folder), or a requested +branch deletion is refused by git, the entry is still removed from the metadata +and Prowl shows an alert listing what was left on disk. Save cannot be canceled once it has started. After a successful save the sidebar reloads and a **Workspace saved** toast appears. diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceEditing.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceEditing.swift index 69b8d915..6cdf92ac 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceEditing.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceEditing.swift @@ -108,27 +108,40 @@ extension RepositoriesFeature { return .run { send in do { let result = try await ProjectWorkspace.update(request, gitRunner: gitRunner) - // Branch deletion goes through the guarded entry point so protected - // branches survive, exactly like workspace removal. + // The save is committed at this point. Branch deletion goes through + // the guarded entry point so protected branches survive, exactly like + // workspace removal; a refused deletion is reported with the other + // cleanup failures rather than hidden behind the success toast. + var cleanupFailures = result.cleanupFailures for removal in result.completedRemovals where removal.deleteBranch { guard let sourceLocation = removal.entry.sourceLocation, let branchName = removal.entry.branchName else { continue } - let outcome = try? await gitClient.deleteLocalBranch( - branchName, - URL(fileURLWithPath: sourceLocation), - true - ) - if case .protected? = outcome { + do { + let outcome = try await gitClient.deleteLocalBranch( + branchName, + URL(fileURLWithPath: sourceLocation), + true + ) + if case .protected = outcome { + workspaceEditingLog.warning( + "Skipped deleting protected branch \(branchName) in \(sourceLocation)") + } + } catch { workspaceEditingLog.warning( - "Skipped deleting protected branch \(branchName) in \(sourceLocation)") + "Could not delete branch \(branchName) in \(sourceLocation): \(error.localizedDescription)") + cleanupFailures.append( + ProjectWorkspaceCleanupFailure( + entryID: removal.entry.id, + entryName: removal.entry.name, + message: String( + localized: "Branch \(branchName) was not deleted: \(error.localizedDescription)") + )) } } - await send( - .workspaceEditing( - .workspaceSaved(repositoryID, cleanupFailures: result.cleanupFailures))) + await send(.workspaceEditing(.workspaceSaved(repositoryID, cleanupFailures: cleanupFailures))) } catch { workspaceEditingLog.warning("Workspace save failed: \(error.localizedDescription)") await send(.workspaceEditing(.workspaceSaveFailed(error.localizedDescription))) diff --git a/supacodeTests/RepositoriesFeatureWorkspaceEditingTests.swift b/supacodeTests/RepositoriesFeatureWorkspaceEditingTests.swift index da5caca6..3a1df961 100644 --- a/supacodeTests/RepositoriesFeatureWorkspaceEditingTests.swift +++ b/supacodeTests/RepositoriesFeatureWorkspaceEditingTests.swift @@ -202,6 +202,69 @@ struct RepositoriesFeatureWorkspaceEditingTests { #expect(ProjectWorkspace.load(from: rootURL)?.repositories.map(\.id) == ["app"]) } + @Test func saveWorkspaceReportsFailedBranchDeletionAfterCommittedSave() async throws { + let rootURL = try makeWorkspaceOnDisk( + title: "Branches", + repositories: [ + ProjectWorkspaceRepositoryEntry(id: "app", name: "App", path: "app"), + ProjectWorkspaceRepositoryEntry( + id: "api", name: "API", path: "api", sourceKind: .localRepository, + sourceLocation: "/tmp/source/api", branchName: "feature/api"), + ] + ) + defer { try? FileManager.default.removeItem(at: rootURL) } + try FileManager.default.createDirectory( + at: rootURL.appending(path: "api"), withIntermediateDirectories: true) + let repositoryID = rootURL.path(percentEncoded: false) + let loaded = try #require(ProjectWorkspace.load(from: rootURL)) + var initialState = RepositoriesFeature.State() + initialState.repositories = [ + Repository(id: repositoryID, rootURL: rootURL, name: "Branches", kind: .plain, worktrees: [], workspace: loaded) + ] + initialState.repositoryRoots = [rootURL] + initialState.workspaceEditor = WorkspaceEditorFeature.State( + editing: loaded, rootURL: rootURL, repositoryID: repositoryID) + let deleteAttempts = LockIsolated(0) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + // `git worktree remove` succeeds; the branch deletion is refused. + $0.shellClient.runLoginImpl = { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + $0.gitClient.deleteLocalBranch = { _, _, _ in + deleteAttempts.withValue { $0 += 1 } + throw GitClientError.commandFailed(command: "git branch -D feature/api", message: "checked out elsewhere") + } + $0.repositoryPersistence.loadRepositoryEntries = { + [PersistedRepositoryEntry(path: rootURL.path(percentEncoded: false), kind: .plain)] + } + $0.repositoryPersistence.saveRepositoryEntries = { _ in } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + } + store.exhaustivity = .off + + let api = try #require(loaded.repositories.first { $0.id == "api" }) + await store.send( + .workspaceEditing( + .saveWorkspace( + ProjectWorkspaceUpdateRequest( + rootURL: rootURL, + title: "Branches", + members: [.existing(loaded.repositories[0])], + removals: [ProjectWorkspaceRepositoryRemoval(entry: api, deleteFiles: true, deleteBranch: true)], + updatedAt: Date() + )))) + await store.receive(\.workspaceEditing.workspaceSaved) { + $0.workspaceEditor = nil + } + await store.finish() + + #expect(deleteAttempts.value == 1) + // The save is committed; the branch that was explicitly requested but + // not deleted must still be surfaced to the user. + #expect(ProjectWorkspace.load(from: rootURL)?.repositories.map(\.id) == ["app"]) + #expect(store.state.alert != nil) + } + @Test func saveWorkspaceFailureKeepsEditorOpenWithMessage() async throws { let missingRoot = FileManager.default.temporaryDirectory .appending(path: "prowl-missing-\(UUID().uuidString)") -- 2.51.2 From aabef291c272559f90759f6dab1d7827d8de670d Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 25 Sep 2026 17:44:18 +0800 Subject: [PATCH 10/15] Scope the creation folder resolver to the creation sheet The unique-folder lookup started by New Workspace is cancelled when the sheet goes away and its result is ignored unless the presented editor is still in create mode, so a late result cannot rewrite the root of an edit session opened right after. --- docs-ai/072-workspace-editing/001-action.md | 1 + ...epositoriesFeature+WorkspaceCreation.swift | 14 ++++++++--- .../Reducer/RepositoriesFeature.swift | 1 + ...sitoriesFeatureWorkspaceEditingTests.swift | 23 +++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/docs-ai/072-workspace-editing/001-action.md b/docs-ai/072-workspace-editing/001-action.md index f4394b22..3f626cd1 100644 --- a/docs-ai/072-workspace-editing/001-action.md +++ b/docs-ai/072-workspace-editing/001-action.md @@ -9,6 +9,7 @@ | 2026-09-25 | `WorkspaceCreationPromptFeature` → `WorkspaceEditorFeature` with `mode`, existing-member rows, staged removal, reorder, task links, description; `WorkspaceCreationPromptView` → `WorkspaceEditorView` | PR #830 | | 2026-09-25 | `RepositoriesFeature+WorkspaceEditing.swift` (`workspaceEditing` action family), entry points in sidebar, detail view, Settings, Command Palette, Worktrees menu | PR #830 | | 2026-09-25 | Tests, `docs/` manual updates, this record | PR #830 | +| 2026-09-25 | Review Loop round 3 (Pi Reviewer): the creation sheet's default-folder resolver is cancelled on dismissal and its result only applies to a `.create` editor, so it can never rewrite an edit session's root | PR #830 | | 2026-09-25 | Review Loop round 2 (Pi Reviewer): a refused branch deletion after a committed save is reported in the cleanup alert instead of vanishing behind the success toast | PR #830 | | 2026-09-25 | Review Loop round 1 (Pi Reviewer): never delete a remote entry without a recorded source; surface the main window synchronously before the Settings-originated request; one interleaved member order so added rows can be moved among existing ones | PR #830 | diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift index 3ee5f15a..b58b1939 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift @@ -49,11 +49,15 @@ extension RepositoriesFeature { let resolved = ProjectWorkspace.uniqueWorkspaceRootPath(folderName: folderName) await send(.workspaceCreation(.defaultRootPathResolved(path: resolved, requestedRootPath: requestedRootPath))) } + .cancellable(id: CancelID.workspaceRootPathResolution, cancelInFlight: true) case .defaultRootPathResolved(let path, let requestedRootPath): // Only adopt the de-duplicated path if the user has not edited the field - // since the prompt opened. - guard state.workspaceEditor?.rootPath == requestedRootPath else { + // since the prompt opened, and only for the creation sheet: an edit + // session opened right after a canceled creation must keep its own root. + guard let editor = state.workspaceEditor, editor.mode == .create, + editor.rootPath == requestedRootPath + else { return .none } state.workspaceEditor?.rootPath = path @@ -63,10 +67,14 @@ extension RepositoriesFeature { let wasCreating = state.workspaceEditor?.isSaving == true state.workspaceEditor = nil guard wasCreating else { - return .cancel(id: CancelID.workspaceCreation) + return .merge( + .cancel(id: CancelID.workspaceCreation), + .cancel(id: CancelID.workspaceRootPathResolution) + ) } return .merge( .cancel(id: CancelID.workspaceCreation), + .cancel(id: CancelID.workspaceRootPathResolution), .send(.showToast(.warning(String(localized: "Workspace creation canceled")))) ) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 90cca524..7832e294 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -106,6 +106,7 @@ struct RepositoriesFeature { static let worktreePromptLoad = "repositories.worktreePromptLoad" static let worktreePromptValidation = "repositories.worktreePromptValidation" static let workspaceCreation = "repositories.workspaceCreation" + static let workspaceRootPathResolution = "repositories.workspaceRootPathResolution" static let workspaceChildrenRefresh = "repositories.workspaceChildrenRefresh" static func archiveScript(_ worktreeID: Worktree.ID) -> String { "repositories.archiveScript.\(worktreeID)" diff --git a/supacodeTests/RepositoriesFeatureWorkspaceEditingTests.swift b/supacodeTests/RepositoriesFeatureWorkspaceEditingTests.swift index 3a1df961..6b138c15 100644 --- a/supacodeTests/RepositoriesFeatureWorkspaceEditingTests.swift +++ b/supacodeTests/RepositoriesFeatureWorkspaceEditingTests.swift @@ -90,6 +90,29 @@ struct RepositoriesFeatureWorkspaceEditingTests { await store.send(.workspaceEditing(.promptRequested("/tmp/git", removingChildID: nil))) } + @Test func staleCreationRootPathResultDoesNotTouchAnEditSession() async { + // Creation resolves a unique default folder off the main actor; a result + // that lands after the creation sheet was canceled and an edit sheet + // opened must not rewrite the edit session's root. + let rootURL = URL(fileURLWithPath: "/Users/someone/.prowl/workspaces/Workspace") + let workspace = ProjectWorkspace( + id: rootURL.path(percentEncoded: false), title: "Workspace", + repositories: [ProjectWorkspaceRepositoryEntry(id: "app", name: "App", path: "app")]) + var initialState = RepositoriesFeature.State() + initialState.workspaceEditor = WorkspaceEditorFeature.State( + editing: workspace, rootURL: rootURL, repositoryID: rootURL.path(percentEncoded: false)) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } + + await store.send( + .workspaceCreation( + .defaultRootPathResolved( + path: rootURL.path(percentEncoded: false) + "-2", + requestedRootPath: rootURL.path(percentEncoded: false)))) + #expect(store.state.workspaceEditor?.rootPath == rootURL.path(percentEncoded: false)) + } + @Test func saveWorkspaceWritesMetadataReloadsAndToasts() async throws { let rootURL = try makeWorkspaceOnDisk( title: "Before", -- 2.51.2 From bb1257776a06f6ea6ced41d9f3ae81f4a69657d3 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 25 Sep 2026 17:51:52 +0800 Subject: [PATCH 11/15] Keep an open workspace editor when New Workspace is requested The sheet is a single presentation slot; a creation request while an edit sheet is up (unsaved edits or an in-flight save) is ignored instead of replacing it. --- docs-ai/072-workspace-editing/001-action.md | 1 + ...epositoriesFeature+WorkspaceCreation.swift | 5 +++++ ...sitoriesFeatureWorkspaceEditingTests.swift | 20 +++++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/docs-ai/072-workspace-editing/001-action.md b/docs-ai/072-workspace-editing/001-action.md index 3f626cd1..5453666b 100644 --- a/docs-ai/072-workspace-editing/001-action.md +++ b/docs-ai/072-workspace-editing/001-action.md @@ -9,6 +9,7 @@ | 2026-09-25 | `WorkspaceCreationPromptFeature` → `WorkspaceEditorFeature` with `mode`, existing-member rows, staged removal, reorder, task links, description; `WorkspaceCreationPromptView` → `WorkspaceEditorView` | PR #830 | | 2026-09-25 | `RepositoriesFeature+WorkspaceEditing.swift` (`workspaceEditing` action family), entry points in sidebar, detail view, Settings, Command Palette, Worktrees menu | PR #830 | | 2026-09-25 | Tests, `docs/` manual updates, this record | PR #830 | +| 2026-09-25 | Review Loop round 4 (Pi Reviewer): New Workspace… no longer replaces an open editor sheet (unsaved edits or an in-flight save survive) | PR #830 | | 2026-09-25 | Review Loop round 3 (Pi Reviewer): the creation sheet's default-folder resolver is cancelled on dismissal and its result only applies to a `.create` editor, so it can never rewrite an edit session's root | PR #830 | | 2026-09-25 | Review Loop round 2 (Pi Reviewer): a refused branch deletion after a committed save is reported in the cleanup alert instead of vanishing behind the success toast | PR #830 | | 2026-09-25 | Review Loop round 1 (Pi Reviewer): never delete a remote entry without a recorded source; surface the main window synchronously before the Settings-originated request; one interleaved member order so added rows can be moved among existing ones | PR #830 | diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift index b58b1939..dd36029a 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift @@ -32,6 +32,11 @@ extension RepositoriesFeature { ) -> Effect { switch action { case .promptRequested: + // The sheet is one presentation slot: never replace an open editor (and + // its unsaved edits or in-flight save) with a fresh creation sheet. + guard state.workspaceEditor == nil else { + return .none + } let candidates = state.workspaceCreationCandidates let title = "Workspace" // Seed with the collision-free base path synchronously (pure path math), diff --git a/supacodeTests/RepositoriesFeatureWorkspaceEditingTests.swift b/supacodeTests/RepositoriesFeatureWorkspaceEditingTests.swift index 6b138c15..98e252b6 100644 --- a/supacodeTests/RepositoriesFeatureWorkspaceEditingTests.swift +++ b/supacodeTests/RepositoriesFeatureWorkspaceEditingTests.swift @@ -90,6 +90,26 @@ struct RepositoriesFeatureWorkspaceEditingTests { await store.send(.workspaceEditing(.promptRequested("/tmp/git", removingChildID: nil))) } + @Test func newWorkspaceRequestDoesNotReplaceAnOpenEditor() async { + let rootURL = URL(fileURLWithPath: "/tmp/ws") + let workspace = ProjectWorkspace( + id: "/tmp/ws", title: "Open", + repositories: [ProjectWorkspaceRepositoryEntry(id: "app", name: "App", path: "app")]) + var initialState = RepositoriesFeature.State() + initialState.workspaceEditor = WorkspaceEditorFeature.State( + editing: workspace, rootURL: rootURL, repositoryID: "/tmp/ws") + initialState.workspaceEditor?.title = "Unsaved rename" + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } + + // Menu and palette entries stay enabled while the sheet is up; the + // request must not discard the unsaved edit. + await store.send(.workspaceCreation(.promptRequested)) + #expect(store.state.workspaceEditor?.title == "Unsaved rename") + #expect(store.state.workspaceEditor?.mode == .edit(repositoryID: "/tmp/ws")) + } + @Test func staleCreationRootPathResultDoesNotTouchAnEditSession() async { // Creation resolves a unique default folder off the main actor; a result // that lands after the creation sheet was canceled and an edit sheet -- 2.51.2 From 98478ba41120f28355f558ac61705a1a5446bc48 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 25 Sep 2026 18:05:59 +0800 Subject: [PATCH 12/15] Use the workspace title as the Settings name placeholder; drop the unreachable detail-view entry from docs Selecting a workspace always opens its terminal, so the workspace detail view (and its Edit Workspace button) is not reachable today; the manual no longer lists it. The Settings Name field now falls back to the workspace title instead of the folder name. --- docs-ai/072-workspace-editing/000-plan.md | 3 ++- docs-ai/072-workspace-editing/001-action.md | 6 ++++++ docs/components/workspaces.md | 1 - .../Features/Settings/Views/RepositorySettingsView.swift | 3 ++- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs-ai/072-workspace-editing/000-plan.md b/docs-ai/072-workspace-editing/000-plan.md index 903ac124..5227984c 100644 --- a/docs-ai/072-workspace-editing/000-plan.md +++ b/docs-ai/072-workspace-editing/000-plan.md @@ -42,7 +42,8 @@ one editor surface instead of two. Bootstrap scripts stay out of scope. - **Reorder** members (move up / down) so the sidebar and metadata list follow the user's mental order. - **Discoverable entry points:** sidebar workspace header menu and child row context menu, - a button on the workspace detail view, a button in Settings → the workspace's page, + a button on the workspace detail view (see the action log: not reachable today), a + button in Settings → the workspace's page, the Command Palette, and the File menu next to "New Workspace…". - **Creation parity:** the create sheet gains the same optional Description, Task Links and per-repository Role fields so a workspace can be fully described up front. diff --git a/docs-ai/072-workspace-editing/001-action.md b/docs-ai/072-workspace-editing/001-action.md index 5453666b..2301ce61 100644 --- a/docs-ai/072-workspace-editing/001-action.md +++ b/docs-ai/072-workspace-editing/001-action.md @@ -83,6 +83,12 @@ ## Deviations from plan +- The "Edit Workspace…" button on `WorkspaceDetailView` is not reachable in the current + app: selecting a workspace row always opens its terminal (`selectedTerminalWorktree` + returns the plain-folder worktree), so `RepositoryDetailView` is only shown for git + repositories without a selected worktree. The button stays for the day that view is + reachable again; the user manual does not list it as an entry point. + - The plan listed "settings-originated request through `AppFeature`" as a test; it exists, and `AppFeature` uses `appLifecycleClient.surfaceMainWindow` rather than a new client. - `.dismiss` on the sheet cannot be blocked while saving: TCA's `ifLet` clears the child diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index dcabc9a7..71cc0bcc 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -135,7 +135,6 @@ A workspace stays editable after creation. Open the editor from any of: - the sidebar: right-click the workspace row (or its `…` menu) → **Edit Workspace…**, or right-click a child row → **Edit Workspace…** / **Remove from Workspace…**; -- the workspace detail view (select the workspace row) → **Edit Workspace…**; - Settings → the workspace under Repositories → **Edit Workspace…** (the editor opens as a sheet on the main window, which is brought to the front); - the Command Palette → **Edit Workspace** while a workspace or one of its diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index 79934681..aeccd459 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -33,7 +33,8 @@ struct RepositorySettingsView: View { set: { settings.fetchPullRequestState.wrappedValue = $0 }, ) let exampleWorktreePath = store.exampleWorktreePath - let folderName = Repository.name(for: store.rootURL) + // A workspace is named by its metadata title, not its folder. + let folderName = store.workspace?.title ?? Repository.name(for: store.rootURL) @Bindable var workflowsStore = store.scope(state: \.workflows, action: \.workflows) NavigationStack(path: $workflowsStore.scope(state: \.path, action: \.path)) { -- 2.51.2 From ad8e4fa377a895d67e3eecd600c1afa48f55dbb1 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 25 Sep 2026 18:06:50 +0800 Subject: [PATCH 13/15] Record the Debug visual verification in docs-ai 072 --- docs-ai/072-workspace-editing/001-action.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs-ai/072-workspace-editing/001-action.md b/docs-ai/072-workspace-editing/001-action.md index 2301ce61..b61159ed 100644 --- a/docs-ai/072-workspace-editing/001-action.md +++ b/docs-ai/072-workspace-editing/001-action.md @@ -9,6 +9,7 @@ | 2026-09-25 | `WorkspaceCreationPromptFeature` → `WorkspaceEditorFeature` with `mode`, existing-member rows, staged removal, reorder, task links, description; `WorkspaceCreationPromptView` → `WorkspaceEditorView` | PR #830 | | 2026-09-25 | `RepositoriesFeature+WorkspaceEditing.swift` (`workspaceEditing` action family), entry points in sidebar, detail view, Settings, Command Palette, Worktrees menu | PR #830 | | 2026-09-25 | Tests, `docs/` manual updates, this record | PR #830 | +| 2026-09-25 | Debug visual verification in an isolated instance: create sheet, sidebar after create, palette item, edit sheet with staged removal, interleaved reorder, save (toast, disk, branch cleanup), Settings entry surfacing the main window; screenshots on PR #830 | PR #830 | | 2026-09-25 | Review Loop round 4 (Pi Reviewer): New Workspace… no longer replaces an open editor sheet (unsaved edits or an in-flight save survive) | PR #830 | | 2026-09-25 | Review Loop round 3 (Pi Reviewer): the creation sheet's default-folder resolver is cancelled on dismissal and its result only applies to a `.create` editor, so it can never rewrite an edit session's root | PR #830 | | 2026-09-25 | Review Loop round 2 (Pi Reviewer): a refused branch deletion after a committed save is reported in the cleanup alert instead of vanishing behind the success toast | PR #830 | -- 2.51.2 From b982730b2fa89ab0cbba7018f85bfca26b3b5523 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 25 Sep 2026 23:03:54 +0800 Subject: [PATCH 14/15] Split the workspace editor into a workspace panel and a one-repository editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single scrolling sheet mixed metadata with a full configuration block per repository. The Workspace panel now holds Title, Description, Task Links (one per line), the derived folder with Change…, and a repository list whose rows say what Create / Save will do; Add Repository… swaps in a second panel that adds or edits one repository: pick the source (opened, local folder, remote URL with automatic branch loading), then Name, Role, a segmented Link / New Branch / Existing Branch checkout, and an Advanced folder field. Base-ref loading moves into the member editor; RepositoriesFeature no longer relays it. docs-ai 072.002 records the decision. --- docs-ai/072-workspace-editing/000-plan.md | 4 +- .../002-two-level-editor.md | 53 + docs/components/workspaces.md | 50 +- .../RepositoriesFeature+CoreReducer.swift | 3 - ...epositoriesFeature+WorkspaceCreation.swift | 144 -- ...RepositoriesFeature+WorkspaceEditing.swift | 4 +- .../Reducer/RepositoriesFeature.swift | 9 - .../Reducer/WorkspaceEditorFeature.swift | 534 ++----- .../WorkspaceMemberEditorFeature.swift | 492 +++++++ .../Views/WorkspaceEditorView.swift | 1266 +++++------------ .../Views/WorkspaceMemberEditorView.swift | 733 ++++++++++ supacodeTests/RepositoriesFeatureTests.swift | 641 --------- .../WorkspaceEditorFeatureTests.swift | 303 ++-- .../WorkspaceMemberEditorFeatureTests.swift | 271 ++++ 14 files changed, 2155 insertions(+), 2352 deletions(-) create mode 100644 docs-ai/072-workspace-editing/002-two-level-editor.md create mode 100644 supacode/Features/Repositories/Reducer/WorkspaceMemberEditorFeature.swift create mode 100644 supacode/Features/Repositories/Views/WorkspaceMemberEditorView.swift create mode 100644 supacodeTests/WorkspaceMemberEditorFeatureTests.swift diff --git a/docs-ai/072-workspace-editing/000-plan.md b/docs-ai/072-workspace-editing/000-plan.md index 5227984c..d3a47f43 100644 --- a/docs-ai/072-workspace-editing/000-plan.md +++ b/docs-ai/072-workspace-editing/000-plan.md @@ -155,4 +155,6 @@ one editor surface instead of two. Bootstrap scripts stay out of scope. ## Amendments -(none yet) +- Updated 2026-09-25: the single-sheet editor was judged too complex; replaced by a two-level + editor (workspace panel + one-repository-at-a-time panel) — see + [002-two-level-editor.md](002-two-level-editor.md) diff --git a/docs-ai/072-workspace-editing/002-two-level-editor.md b/docs-ai/072-workspace-editing/002-two-level-editor.md new file mode 100644 index 00000000..76514c70 --- /dev/null +++ b/docs-ai/072-workspace-editing/002-two-level-editor.md @@ -0,0 +1,53 @@ +# 072.002 — Two-level workspace editor + +## Context + +The first editor (001) put everything on one scrolling sheet: title, folder, description, +task-link rows, and one full configuration block per repository (name, role, folder, +source path, checkout mode, branch, base ref). onevcat's verdict after the Debug pass: it is +the most complex panel in the app, has no guidance, and the decision that matters most +(how each repository is materialized) sits at the bottom of a long form. Agreed direction: +no panel may have more than about five interactive spots; metadata and per-repository +configuration live on separate panels. + +## Decision + +- **Noun:** *Repository* (`Add Repository…`). The metadata key is `repositories`, the + sidebar and CLI say repository; "worktree" is only one of three checkout kinds and + "project" has no meaning in Prowl. +- **Panel A — Workspace** (create and edit): one guidance sentence, then Title, + Description (one multi-line field), Task Links (one multi-line field, one link per + line; no dynamic rows), Folder shown as the derived path with a quiet **Change…**, the + repository list, and **Add Repository…**. The list explains what Create / Save will do + per row ("Link → shares the live checkout at …", "New branch X from Y → worktree", + "Clone … @ ref"), with hover Edit / Remove, Undo for staged removals, and Move Up / Down + in the row's context menu. A footer line summarizes the outcome; Create is disabled + with no repository. +- **Panel B — Add / Edit Repository**, one repository at a time, replacing Panel A inside + the same sheet (Back returns; no stacked sheets). Step 1 picks the source: Opened in + Prowl, Local folder (open panel), Remote URL (branches load automatically on Return or + after a short pause; no Load button). Step 2: Name, Role, Checkout as a segmented + **Link / New branch / Existing branch** control, the one field that mode needs + (branch + base, or existing ref), and an **Advanced** disclosure for the folder inside + the workspace. Editing an existing member shows Name / Role, a read-only source and + checkout summary, and a destructive **Remove from Workspace…** that reveals the file / + branch deletion opt-ins. +- Description and Task Links are not folded behind a disclosure (three fields is fine). + +## Change + +- `WorkspaceEditorFeature` keeps the workspace-level state (title, description, + `taskLinksText`, root path, existing / added members, `memberOrder`) and presents + `WorkspaceMemberEditorFeature` for one row. Per-row actions, the remote prompt, and + base-ref loading move into the member editor, which loads refs itself through + `GitClientDependency`; `RepositoriesFeature` no longer relays `refreshBaseRefs`. +- `WorkspaceEditorView` renders Panel A and swaps in `WorkspaceMemberEditorView` + (Panel B) while a member is being edited. +- Tests: the row-level creation tests move from `RepositoriesFeatureTests` to + `WorkspaceMemberEditorFeatureTests`; `WorkspaceEditorFeatureTests` covers the + workspace-level flow and the member commit / remove / reorder wiring. +- `docs/components/workspaces.md` describes the two panels. + +## Refs + +- PR #830 (same PR as the first editor; the redesign supersedes its sheet before merge). diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index 71cc0bcc..91ec18ec 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -26,10 +26,25 @@ shared folder, materializes the selected repositories, writes workspace needs at least one repository; more can be added later (see [Editing a workspace](#editing-a-workspace)). -Besides the title and folder, the creation sheet takes an optional -**Description**, a list of **Task Links** (URLs or issue keys, one per row), -and an optional **Role** per repository (`app`, `backend`, `docs`, …). All of -them land in `.prowl/workspace.json` so agents can read them. +The sheet has two levels. The **Workspace** panel holds the metadata: a +**Title** (the folder name follows it until you press **Change…**), an optional +**Description**, optional **Task Links** (URLs or issue keys, one per line), +and the repository list with **Add Repository…**. Every row says in one line +what Create will do with it ("Link → shares the live checkout at …", "New +branch feature/x from main → worktree", "Clone git@… @ origin/main"), and a +footer line summarizes the whole operation. Create is disabled until at least +one repository is listed. + +**Add Repository…** opens the second level inside the same sheet, one +repository at a time. First pick the source — **Opened in Prowl** (a list of +the sidebar repositories), **Local Folder…** (an open panel), or **Remote URL** +(branches load automatically once you stop typing or press Return). Then set +**Name**, an optional **Role** (`app`, `backend`, `docs`, …), and the +**Checkout**: **Link**, **New Branch** (name plus base branch), or **Existing +Branch**. The folder name inside the workspace lives under **Advanced** and +defaults to the repository name. **Add** returns to the workspace panel with +the new row; **Back** discards it. All of this lands in `.prowl/workspace.json` +so agents can read it. While a workspace is being created the prompt shows a spinner. **Cancel** stops the creation and rolls back everything created so far: cloned folders, created @@ -142,24 +157,25 @@ A workspace stays editable after creation. Open the editor from any of: - the Worktrees menu → **Edit Workspace...** (enabled when a workspace is selected). -The editor is the same sheet as creation, pre-filled from +The editor is the same two-level sheet as creation, pre-filled from `.prowl/workspace.json` (re-read from disk when it opens, so edits made outside Prowl are respected). It lets you change: - **Title**, **Description**, and **Task Links**. The title is display-only; the folder on disk keeps its name. -- **Name** and **Role** of every existing repository. Source, path, and - checkout are shown as read-only provenance; to change them, remove the - repository and add it again. -- **Order**: move any repository up or down, including ones added in the - same edit. The sidebar and the metadata follow the new order. -- **Added repositories**: the same **Add Opened** / **Add Remote** / **Add - Local** sources and Link / Create Branch / Use Existing checkouts as - creation. They are materialized when you save. -- **Removed repositories**: the trash button marks a repository for removal - (**Undo** restores it). By default only the metadata entry goes away and the - folder stays. Tick **Also delete the folder inside the workspace** to remove - what Prowl materialized: the symlink for a linked repository (the source is +- **Name** and **Role** of every existing repository: hover a row and press the + pencil (or right-click → **Edit…**). Source and checkout are shown as a + read-only summary; to change them, remove the repository and add it again. +- **Order**: right-click a row → **Move Up** / **Move Down**, for existing and + newly added rows alike. The sidebar and the metadata follow the new order. +- **Added repositories**: **Add Repository…** with the same sources and + checkouts as creation. They are materialized when you save and show + "Added on save" until then. +- **Removed repositories**: the trash button on a row (or **Remove from + Workspace on save** inside the row's editor) marks it for removal; **Undo** + restores it. By default only the metadata entry goes away and the folder + stays. Tick **Also delete the folder inside the workspace** to remove what + Prowl materialized: the symlink for a linked repository (the source is untouched), `git worktree remove --force` plus the folder for a worktree, or the cloned folder for a remote. Worktree entries with a recorded branch additionally offer **Delete branch … in the source repository**, which goes diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index 4fe9975e..2ea9a458 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -840,9 +840,6 @@ extension RepositoriesFeature { } return .send(.workspaceCreation(.promptCanceled)) - case .workspaceEditor(.presented(.delegate(.baseRefSourceChanged(let repositoryID)))): - return .send(.workspaceCreation(.refreshBaseRefs(repositoryID))) - case .workspaceEditor(.presented(.delegate(.submit(.create(let draft))))): return .send(.workspaceCreation(.createWorkspace(draft))) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift index dd36029a..1b737af2 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceCreation.swift @@ -19,12 +19,6 @@ extension RepositoriesFeature.State { nonisolated private let workspaceLog = SupaLogger("workspace") -nonisolated private struct WorkspaceBaseRefsResult: Sendable { - var options: [GitBranchRefOption] = [] - var defaultBaseRef: String? - var errorMessage: String? -} - extension RepositoriesFeature { func reduceWorkspaceCreation( state: inout State, @@ -83,28 +77,6 @@ extension RepositoriesFeature { .send(.showToast(.warning(String(localized: "Workspace creation canceled")))) ) - case .refreshBaseRefs(let repositoryID): - guard let repository = state.workspaceEditor?.repositories[id: repositoryID] else { - return .none - } - return workspaceBaseRefsEffect(for: [repository]) - - case .baseRefsLoaded( - let repositoryID, let sourceKind, let sourceLocation, let options, let defaultBaseRef, let errorMessage - ): - Self.applyLoadedBaseRefs( - into: &state, - repositoryID: repositoryID, - sourceKind: sourceKind, - sourceLocation: sourceLocation, - result: WorkspaceBaseRefsResult( - options: options, - defaultBaseRef: defaultBaseRef, - errorMessage: errorMessage - ) - ) - return .none - case .createWorkspace(let draft): state.workspaceEditor?.isSaving = true state.workspaceEditor?.validationMessage = nil @@ -153,41 +125,6 @@ extension RepositoriesFeature { } } - private static func trimmedNonEmpty(_ value: String?) -> String? { - guard let value else { - return nil - } - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - private static func applyLoadedBaseRefs( - into state: inout State, - repositoryID: Repository.ID, - sourceKind: ProjectWorkspaceRepositorySourceKind, - sourceLocation: String, - result: WorkspaceBaseRefsResult - ) { - guard var repository = state.workspaceEditor?.repositories[id: repositoryID], - repository.sourceKind == sourceKind, - repository.sourceLocation == sourceLocation - else { - return - } - if let errorMessage = result.errorMessage { - state.workspaceEditor?.validationMessage = errorMessage - } - let baseRef = trimmedNonEmpty(repository.baseRef) - let baseRefOptions = ProjectWorkspaceCreationRepository.normalizedBaseRefOptions(result.options) - repository.baseRefOptions = baseRefOptions - if let baseRef, baseRefOptions.contains(where: { $0.ref == baseRef }) { - repository.baseRef = baseRef - } else { - repository.baseRef = result.defaultBaseRef - } - state.workspaceEditor?.repositories[id: repositoryID] = repository - } - static func workspaceGitRunner( shellClient: ShellClient, resolveGit: @escaping @Sendable () async throws -> GitExecutable = { @@ -212,85 +149,4 @@ extension RepositoriesFeature { } } } - - private func workspaceBaseRefsEffect(for repositories: [ProjectWorkspaceCreationRepository]) -> Effect { - guard !repositories.isEmpty else { - return .none - } - let gitClient = gitClient - return .run { send in - for repository in repositories { - let result = await Self.workspaceBaseRefs(for: repository, gitClient: gitClient) - await send( - .workspaceCreation( - .baseRefsLoaded( - repositoryID: repository.id, - sourceKind: repository.sourceKind, - sourceLocation: repository.sourceLocation, - options: result.options, - defaultBaseRef: result.defaultBaseRef, - errorMessage: result.errorMessage - ) - ) - ) - } - } - } - - private static func workspaceBaseRefs( - for repository: ProjectWorkspaceCreationRepository, - gitClient: GitClientDependency - ) async -> WorkspaceBaseRefsResult { - switch repository.sourceKind { - case .remote: - return WorkspaceBaseRefsResult() - - case .existingPath, .localRepository, .bareRepository: - guard let sourceURL = repository.localSourceURL else { - return WorkspaceBaseRefsResult() - } - - let repositoryURL: URL - if repository.sourceKind == .bareRepository { - repositoryURL = sourceURL - } else { - repositoryURL = (try? await gitClient.repoRoot(sourceURL)) ?? sourceURL - } - - async let automaticBaseRefTask = gitClient.automaticWorktreeBaseRef(repositoryURL) - async let refsTask = gitClient.branchRefOptions(repositoryURL) - let automaticBaseRef = await automaticBaseRefTask - let refs: [GitBranchRefOption] - var errorMessage: String? - do { - refs = try await refsTask - } catch { - refs = [] - let name = repository.name.trimmingCharacters(in: .whitespacesAndNewlines) - let displayName = name.isEmpty ? repositoryURL.lastPathComponent : name - errorMessage = String( - localized: "Could not read branches for \(displayName): \(error.localizedDescription)" - ) - workspaceLog.warning( - "Branch detection failed for \(repositoryURL.path(percentEncoded: false)): \(error)" - ) - } - let options = ProjectWorkspaceCreationRepository.baseRefOptions( - automaticBaseRef: automaticBaseRef, - options: refs - ) - let defaultBaseRef = - automaticBaseRef != nil || !refs.isEmpty - ? ProjectWorkspaceCreationRepository.preferredBaseRef( - automaticBaseRef: automaticBaseRef, - options: options - ) - : nil - return WorkspaceBaseRefsResult( - options: options, - defaultBaseRef: defaultBaseRef, - errorMessage: errorMessage - ) - } - } } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceEditing.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceEditing.swift index 6cdf92ac..101a5d91 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceEditing.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceEditing.swift @@ -32,13 +32,11 @@ extension RepositoriesFeature { else { return .none } - let uuid = uuid var editor = WorkspaceEditorFeature.State( editing: workspace, rootURL: repository.rootURL, repositoryID: repositoryID, - openedRepositoryCandidates: state.workspaceCreationCandidates, - taskLinkIDs: { _ in uuid().uuidString } + openedRepositoryCandidates: state.workspaceCreationCandidates ) if let removingChildID, let entry = workspace.repositories.first(where: { entry in diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 7832e294..8bf5de88 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -271,15 +271,6 @@ struct RepositoriesFeature { case defaultRootPathResolved(path: String, requestedRootPath: String) case promptCanceled case promptDismissed - case refreshBaseRefs(Repository.ID) - case baseRefsLoaded( - repositoryID: Repository.ID, - sourceKind: ProjectWorkspaceRepositorySourceKind, - sourceLocation: String, - options: [GitBranchRefOption], - defaultBaseRef: String?, - errorMessage: String? - ) case createWorkspace(ProjectWorkspaceCreationDraft) case workspaceCreated(URL) case workspaceCreationFailed(String) diff --git a/supacode/Features/Repositories/Reducer/WorkspaceEditorFeature.swift b/supacode/Features/Repositories/Reducer/WorkspaceEditorFeature.swift index f1e71200..c826ffc5 100644 --- a/supacode/Features/Repositories/Reducer/WorkspaceEditorFeature.swift +++ b/supacode/Features/Repositories/Reducer/WorkspaceEditorFeature.swift @@ -43,11 +43,6 @@ struct WorkspaceEditorExistingRepository: Equatable, Identifiable, Sendable { } } -struct WorkspaceTaskLinkDraft: Equatable, Identifiable, Sendable { - let id: String - var value: String -} - enum WorkspaceEditorSubmission: Equatable, Sendable { case create(ProjectWorkspaceCreationDraft) case update(ProjectWorkspaceUpdateRequest) @@ -74,12 +69,11 @@ enum WorkspaceEditorMember: Equatable, Identifiable { } } +/// The first level of the workspace editor: metadata plus the member list. +/// Per-repository configuration happens in `WorkspaceMemberEditorFeature`, +/// presented one row at a time. @Reducer struct WorkspaceEditorFeature { - private enum CancelID { - static let remoteRepositoryPromptLoad = "workspaceEditor.remoteRepositoryPromptLoad" - } - enum Mode: Equatable, Sendable { case create case edit(repositoryID: Repository.ID) @@ -100,18 +94,18 @@ struct WorkspaceEditorFeature { var openedRepositoryCandidates: IdentifiedArrayOf var title: String var description: String - var taskLinks: IdentifiedArrayOf + /// One link or identifier per line. + var taskLinksText: String var rootPath: String var isRootPathDirty = false var validationMessage: String? var validationTarget: ValidationTarget? - var validationRequestID = 0 var isSaving = false - var remoteRepositoryPrompt: RemoteRepositoryPromptState? /// Explicit member order, changed only by move actions. Keys for rows /// that were added since the last move are appended by `orderedMemberKeys`, /// existing members first, so add/remove never have to touch it. var memberOrder: [WorkspaceEditorMemberKey] = [] + @Presents var memberEditor: WorkspaceMemberEditorFeature.State? var availableOpenedRepositories: [ProjectWorkspaceCreationRepository] { openedRepositoryCandidates.filter { repositories[id: $0.id] == nil } @@ -130,6 +124,12 @@ struct WorkspaceEditorFeature { existingRepositories.contains(where: \.isMarkedForRemoval) } + var taskLinks: [String] { + taskLinksText.split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + /// `memberOrder` reconciled with the rows that currently exist: stale /// keys drop out, new rows append (existing members before added ones). var orderedMemberKeys: [WorkspaceEditorMemberKey] { @@ -173,7 +173,7 @@ struct WorkspaceEditorFeature { ) self.title = title description = "" - taskLinks = [] + taskLinksText = "" self.rootPath = rootPath } @@ -181,8 +181,7 @@ struct WorkspaceEditorFeature { editing workspace: ProjectWorkspace, rootURL: URL, repositoryID: Repository.ID, - openedRepositoryCandidates: [ProjectWorkspaceCreationRepository] = [], - taskLinkIDs: (Int) -> String = { "task-link-\($0)" } + openedRepositoryCandidates: [ProjectWorkspaceCreationRepository] = [] ) { mode = .edit(repositoryID: repositoryID) existingRepositories = IdentifiedArray( @@ -196,12 +195,7 @@ struct WorkspaceEditorFeature { ) title = workspace.title description = workspace.description - taskLinks = IdentifiedArray( - workspace.taskLinks.enumerated().map { index, link in - WorkspaceTaskLinkDraft(id: taskLinkIDs(index), value: link) - }, - uniquingIDsWith: { current, _ in current } - ) + taskLinksText = workspace.taskLinks.joined(separator: "\n") var path = rootURL.standardizedFileURL.path(percentEncoded: false) while path.count > 1, path.hasSuffix("/") { path.removeLast() @@ -218,78 +212,29 @@ struct WorkspaceEditorFeature { mutating func setValidation(_ message: String, target: ValidationTarget?) { validationMessage = message validationTarget = target - validationRequestID += 1 } } enum ValidationTarget: Equatable, Sendable { case title case rootPath - case repository(Repository.ID, RepositoryField) - case existingRepository(String) - } - - enum RepositoryField: Equatable, Sendable { - case name - case source - case branchName - case baseRef - } - - @ObservableState - struct RemoteRepositoryPromptState: Equatable { - var url = "" - var name = "" - var branchOptions: [GitBranchRefOption] = [] - var defaultBaseRef: String? - var validationMessage: String? - var isLoading = false - - var displayName: String { - let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? "Remote Repository" : trimmed - } + case repositories } enum Action: BindableAction, Equatable { case binding(BindingAction) case titleChanged(String) case descriptionChanged(String) - case addTaskLinkButtonTapped - case taskLinkChanged(String, String) - case removeTaskLink(String) - case rootPathChanged(String) + case taskLinksTextChanged(String) case automaticRootPathResolved(path: String, requestedRootPath: String) - case addOpenedRepository(Repository.ID) - case addRepositoryFromURL(ProjectWorkspaceRepositorySourceKind, String) - case addRemoteButtonTapped - case remoteRepositoryPromptURLChanged(String) - case remoteRepositoryPromptNameChanged(String) - case remoteRepositoryPromptLoadButtonTapped - case remoteRepositoryPromptLoaded(String, GitRemoteBranchRefs) - case remoteRepositoryPromptFailed(String) - case remoteRepositoryPromptAddButtonTapped - case remoteRepositoryPromptDismissed - case removeRepository(Repository.ID) - case repositorySourceKindChanged(Repository.ID, ProjectWorkspaceRepositorySourceKind) - case repositoryCheckoutModeChanged(Repository.ID, ProjectWorkspaceRepositoryCheckoutMode) - case repositoryNameChanged(Repository.ID, String) - case repositoryRoleChanged(Repository.ID, String) - case repositoryPathChanged(Repository.ID, String) - case repositorySourceChosen(Repository.ID, String) - case repositorySourceLocationChanged(Repository.ID, String) - case repositoryBranchNameChanged(Repository.ID, String) - case repositoryBaseRefChanged(Repository.ID, String) - case repositoryResetLocalBranchChanged(Repository.ID, Bool) - case existingRepositoryNameChanged(String, String) - case existingRepositoryRoleChanged(String, String) - case existingRepositoryMarkedForRemoval(String) - case existingRepositoryRemovalUndone(String) - case existingRepositoryDeleteFilesChanged(String, Bool) - case existingRepositoryDeleteBranchChanged(String, Bool) + case rootPathChosen(String) + case addRepositoryButtonTapped + case editMember(WorkspaceEditorMemberKey) + case removeMember(WorkspaceEditorMemberKey) + case undoRemoval(String) case memberMovedUp(WorkspaceEditorMemberKey) case memberMovedDown(WorkspaceEditorMemberKey) - case rootPathChosen(String) + case memberEditor(PresentationAction) case cancelButtonTapped case submitButtonTapped case delegate(Delegate) @@ -297,14 +242,11 @@ struct WorkspaceEditorFeature { @CasePathable enum Delegate: Equatable { - case baseRefSourceChanged(Repository.ID) case cancel case submit(WorkspaceEditorSubmission) } - @Dependency(\.uuid) var uuid @Dependency(\.date.now) var now - @Dependency(GitClientDependency.self) var gitClient var body: some Reducer { BindingReducer() @@ -334,24 +276,8 @@ struct WorkspaceEditorFeature { state.clearValidation() return .none - case .addTaskLinkButtonTapped: - state.taskLinks.append(WorkspaceTaskLinkDraft(id: uuid().uuidString, value: "")) - state.clearValidation() - return .none - - case .taskLinkChanged(let linkID, let value): - state.taskLinks[id: linkID]?.value = value - state.clearValidation() - return .none - - case .removeTaskLink(let linkID): - state.taskLinks.remove(id: linkID) - state.clearValidation() - return .none - - case .rootPathChanged(let rootPath): - state.rootPath = rootPath - state.isRootPathDirty = true + case .taskLinksTextChanged(let text): + state.taskLinksText = text state.clearValidation() return .none @@ -362,328 +288,93 @@ struct WorkspaceEditorFeature { state.rootPath = path return .none - case .addOpenedRepository(let repositoryID): - guard let repository = state.openedRepositoryCandidates[id: repositoryID], - state.repositories[id: repositoryID] == nil - else { - return .none - } - state.repositories.append(repository) - state.clearValidation() - return .send(.delegate(.baseRefSourceChanged(repositoryID))) - - case .addRemoteButtonTapped: - state.remoteRepositoryPrompt = RemoteRepositoryPromptState() + case .rootPathChosen(let path): + state.rootPath = path + state.isRootPathDirty = true state.clearValidation() return .none - case .remoteRepositoryPromptURLChanged(let url): - state.remoteRepositoryPrompt?.url = url - state.remoteRepositoryPrompt?.validationMessage = nil - state.remoteRepositoryPrompt?.branchOptions = [] - state.remoteRepositoryPrompt?.defaultBaseRef = nil - if state.remoteRepositoryPrompt?.name.trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty == true - { - state.remoteRepositoryPrompt?.name = GitRemoteNaming.repositoryName(fromRemoteURL: url) - } - return .none - - case .remoteRepositoryPromptNameChanged(let name): - state.remoteRepositoryPrompt?.name = name - state.remoteRepositoryPrompt?.validationMessage = nil - return .none - - case .remoteRepositoryPromptLoadButtonTapped: - guard var prompt = state.remoteRepositoryPrompt else { - return .none - } - let url = prompt.url.trimmingCharacters(in: .whitespacesAndNewlines) - guard !url.isEmpty else { - prompt.validationMessage = String(localized: "Remote URL required.") - state.remoteRepositoryPrompt = prompt - return .none - } - prompt.url = url - if prompt.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - prompt.name = GitRemoteNaming.repositoryName(fromRemoteURL: url) - } - prompt.isLoading = true - prompt.validationMessage = nil - state.remoteRepositoryPrompt = prompt - let gitClient = gitClient - return .run { send in - do { - let refs = try await Self.loadRemoteBranchRefs(url, gitClient: gitClient) - await send(.remoteRepositoryPromptLoaded(url, refs)) - } catch { - await send(.remoteRepositoryPromptFailed(error.localizedDescription)) - } - } - .cancellable(id: CancelID.remoteRepositoryPromptLoad, cancelInFlight: true) - - case .remoteRepositoryPromptLoaded(let url, let refs): - guard var prompt = state.remoteRepositoryPrompt, - prompt.url.trimmingCharacters(in: .whitespacesAndNewlines) == url - else { - return .none - } - let options = ProjectWorkspaceCreationRepository.normalizedBaseRefOptions(refs.options) - prompt.isLoading = false - prompt.branchOptions = options - prompt.defaultBaseRef = ProjectWorkspaceCreationRepository.preferredBaseRef( - automaticBaseRef: refs.defaultBaseRef, - options: options - ) - prompt.validationMessage = options.isEmpty ? String(localized: "No remote branches found.") : nil - state.remoteRepositoryPrompt = prompt - return .none - - case .remoteRepositoryPromptFailed(let message): - state.remoteRepositoryPrompt?.isLoading = false - state.remoteRepositoryPrompt?.validationMessage = message - return .none - - case .remoteRepositoryPromptAddButtonTapped: - guard let prompt = state.remoteRepositoryPrompt else { - return .none - } - let url = prompt.url.trimmingCharacters(in: .whitespacesAndNewlines) - guard !url.isEmpty else { - state.remoteRepositoryPrompt?.validationMessage = String(localized: "Remote URL required.") - return .none - } - guard !prompt.branchOptions.isEmpty else { - state.remoteRepositoryPrompt?.validationMessage = String(localized: "Load remote branches before adding.") - return .none - } - let id = uuid().uuidString - state.repositories.append( - ProjectWorkspaceCreationRepository( - id: id, - name: prompt.displayName, - sourceKind: .remote, - sourceLocation: url, - checkoutMode: .useExistingRef, - baseRef: prompt.defaultBaseRef, - baseRefOptions: prompt.branchOptions - ) - ) - state.remoteRepositoryPrompt = nil + case .addRepositoryButtonTapped: + state.memberEditor = .add(openedCandidates: state.availableOpenedRepositories) state.clearValidation() return .none - case .remoteRepositoryPromptDismissed: - state.remoteRepositoryPrompt = nil - return .cancel(id: CancelID.remoteRepositoryPromptLoad) - - case .addRepositoryFromURL(let sourceKind, let path): - guard let rootPath = PathPolicy.normalizePath(path) else { - state.setValidation( - ProjectWorkspaceCreationError.missingRepositorySource("repository").localizedDescription, - target: nil - ) - return .none + case .editMember(let key): + switch key { + case .existing(let id): + guard let existing = state.existingRepositories[id: id] else { return .none } + state.memberEditor = .editExisting(existing) + case .added(let id): + guard let draft = state.repositories[id: id] else { return .none } + state.memberEditor = .editAdded(draft) } - let url = URL(fileURLWithPath: rootPath).standardizedFileURL - let id = uuid().uuidString - state.repositories.append( - ProjectWorkspaceCreationRepository( - id: id, - name: Self.defaultRepositoryName(for: url), - sourceKind: sourceKind, - sourceLocation: rootPath - ) - ) - state.clearValidation() - return .send(.delegate(.baseRefSourceChanged(id))) - - case .removeRepository(let repositoryID): - state.repositories.remove(id: repositoryID) state.clearValidation() return .none - case .repositoryCheckoutModeChanged(let repositoryID, let checkoutMode): - guard var repository = state.repositories[id: repositoryID] else { - return .none - } - guard checkoutMode != .link || repository.sourceKind.supportsLinkCheckout else { - return .none + case .removeMember(let key): + switch key { + case .existing(let id): + guard state.existingRepositories[id: id] != nil else { return .none } + state.existingRepositories[id: id]?.removal = .init() + case .added(let id): + state.repositories.remove(id: id) } - repository.checkoutMode = checkoutMode - state.repositories[id: repositoryID] = repository state.clearValidation() return .none - case .repositorySourceKindChanged(let repositoryID, let sourceKind): - guard var repository = state.repositories[id: repositoryID] else { - return .none - } - repository.sourceKind = sourceKind - repository.baseRef = nil - repository.baseRefOptions = [] - if repository.checkoutMode == .link, !sourceKind.supportsLinkCheckout { - repository.checkoutMode = sourceKind.defaultCheckoutMode - } - if sourceKind == .remote { - repository.sourceLocation = "" - } - state.repositories[id: repositoryID] = repository - state.clearValidation() - guard sourceKind != .remote, repository.localSourceURL != nil else { - return .none - } - return .send(.delegate(.baseRefSourceChanged(repositoryID))) - - case .repositoryNameChanged(let repositoryID, let name): - state.repositories[id: repositoryID]?.name = name + case .undoRemoval(let id): + state.existingRepositories[id: id]?.removal = nil state.clearValidation() return .none - case .repositoryRoleChanged(let repositoryID, let role): - state.repositories[id: repositoryID]?.role = role - state.clearValidation() - return .none - - case .repositoryPathChanged(let repositoryID, let path): - state.repositories[id: repositoryID]?.path = path - state.clearValidation() - return .none - - case .repositorySourceChosen(let repositoryID, let sourceLocation): - guard let rootPath = PathPolicy.normalizePath(sourceLocation) else { - state.setValidation( - ProjectWorkspaceCreationError.missingRepositorySource("repository").localizedDescription, - target: .repository(repositoryID, .source) - ) - return .none - } - guard var repository = state.repositories[id: repositoryID] else { - return .none - } - repository.sourceLocation = rootPath - if repository.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - repository.name = Self.defaultRepositoryName(for: URL(fileURLWithPath: rootPath)) - } - repository.baseRef = nil - repository.baseRefOptions = [] - state.repositories[id: repositoryID] = repository - state.clearValidation() - return .send(.delegate(.baseRefSourceChanged(repositoryID))) - - case .repositorySourceLocationChanged(let repositoryID, let sourceLocation): - guard var repository = state.repositories[id: repositoryID] else { - return .none - } - let sourceLocationChanged = repository.sourceLocation != sourceLocation - repository.sourceLocation = sourceLocation - if sourceLocationChanged { - repository.baseRef = nil - repository.baseRefOptions = [] - } - state.repositories[id: repositoryID] = repository - state.clearValidation() - guard sourceLocationChanged, repository.sourceKind != .remote, - repository.localSourceURL != nil - else { + case .memberMovedUp(let key): + var keys = state.orderedMemberKeys + guard let index = keys.firstIndex(of: key), index > 0 else { return .none } - return .send(.delegate(.baseRefSourceChanged(repositoryID))) - - case .repositoryBranchNameChanged(let repositoryID, let branchName): - state.repositories[id: repositoryID]?.branchName = branchName - state.clearValidation() + keys.swapAt(index, index - 1) + state.memberOrder = keys return .none - case .repositoryBaseRefChanged(let repositoryID, let baseRef): - guard var repository = state.repositories[id: repositoryID] else { - return .none - } - let trimmed = baseRef.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.isEmpty || repository.baseRefOptions.contains(where: { $0.ref == trimmed }) - else { + case .memberMovedDown(let key): + var keys = state.orderedMemberKeys + guard let index = keys.firstIndex(of: key), index < keys.count - 1 else { return .none } - repository.baseRef = trimmed.isEmpty ? nil : trimmed - // A new ref selection invalidates any previous keep/reset choice. - repository.resetLocalBranchToRemote = false - state.repositories[id: repositoryID] = repository - state.clearValidation() - return .none - - case .repositoryResetLocalBranchChanged(let repositoryID, let resetToRemote): - state.repositories[id: repositoryID]?.resetLocalBranchToRemote = resetToRemote - state.clearValidation() + keys.swapAt(index, index + 1) + state.memberOrder = keys return .none - case .existingRepositoryNameChanged(let entryID, let name): - state.existingRepositories[id: entryID]?.name = name - state.clearValidation() + case .memberEditor(.presented(.delegate(.cancel))): + state.memberEditor = nil return .none - case .existingRepositoryRoleChanged(let entryID, let role): - state.existingRepositories[id: entryID]?.role = role + case .memberEditor(.presented(.delegate(.commitAdded(let draft)))): + // A re-edited row keeps its place; a new row appends. + if state.repositories[id: draft.id] != nil { + state.repositories[id: draft.id] = draft + } else { + state.repositories.append(draft) + } + state.memberEditor = nil state.clearValidation() return .none - case .existingRepositoryMarkedForRemoval(let entryID): - guard state.existingRepositories[id: entryID] != nil else { - return .none + case .memberEditor(.presented(.delegate(.commitExisting(let existing)))): + if state.existingRepositories[id: existing.id] != nil { + state.existingRepositories[id: existing.id] = existing } - state.existingRepositories[id: entryID]?.removal = .init() + state.memberEditor = nil state.clearValidation() return .none - case .existingRepositoryRemovalUndone(let entryID): - state.existingRepositories[id: entryID]?.removal = nil + case .memberEditor(.presented(.delegate(.removeAdded(let id)))): + state.repositories.remove(id: id) + state.memberEditor = nil state.clearValidation() return .none - case .existingRepositoryDeleteFilesChanged(let entryID, let deleteFiles): - guard var removal = state.existingRepositories[id: entryID]?.removal else { - return .none - } - removal.deleteFiles = deleteFiles - if !deleteFiles { - removal.deleteBranch = false - } - state.existingRepositories[id: entryID]?.removal = removal - return .none - - case .existingRepositoryDeleteBranchChanged(let entryID, let deleteBranch): - guard let repository = state.existingRepositories[id: entryID], - var removal = repository.removal, - removal.deleteFiles || !deleteBranch, - repository.offersBranchDeletion || !deleteBranch - else { - return .none - } - removal.deleteBranch = deleteBranch - state.existingRepositories[id: entryID]?.removal = removal - return .none - - case .memberMovedUp(let key): - var keys = state.orderedMemberKeys - guard let index = keys.firstIndex(of: key), index > 0 else { - return .none - } - keys.swapAt(index, index - 1) - state.memberOrder = keys - return .none - - case .memberMovedDown(let key): - var keys = state.orderedMemberKeys - guard let index = keys.firstIndex(of: key), index < keys.count - 1 else { - return .none - } - keys.swapAt(index, index + 1) - state.memberOrder = keys - return .none - - case .rootPathChosen(let path): - state.rootPath = path - state.isRootPathDirty = true - state.clearValidation() + case .memberEditor: return .none case .cancelButtonTapped: @@ -709,36 +400,34 @@ struct WorkspaceEditorFeature { guard state.remainingRepositoryCount >= 1 else { state.setValidation( ProjectWorkspaceCreationError.notEnoughRepositories.localizedDescription, - target: nil + target: .repositories ) return .none } + // Rows are validated when they are added; a stale draft is still + // reported here rather than reaching the domain. var plansByID: [Repository.ID: ProjectWorkspaceRepositoryPlan] = [:] for repository in state.repositories { switch Self.plan(for: repository) { case .success(let plan): plansByID[repository.id] = plan case .failure(let error): - state.setValidation( - error.localizedDescription, - target: Self.validationTarget(for: error, repositoryID: repository.id) - ) + state.setValidation(error.localizedDescription, target: .repositories) return .none } } state.clearValidation() + let description = state.description.trimmingCharacters(in: .whitespacesAndNewlines) + let taskLinks = state.taskLinks // Both submissions follow the user's row order, interleaving existing // and added members. let orderedKeys = state.orderedMemberKeys - let plans = orderedKeys.compactMap { key -> ProjectWorkspaceRepositoryPlan? in - guard case .added(let id) = key else { return nil } - return plansByID[id] - } - let description = state.description.trimmingCharacters(in: .whitespacesAndNewlines) - let taskLinks = state.taskLinks.map { $0.value.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } switch state.mode { case .create: + let plans = orderedKeys.compactMap { key -> ProjectWorkspaceRepositoryPlan? in + guard case .added(let id) = key else { return nil } + return plansByID[id] + } return .send( .delegate( .submit( @@ -799,6 +488,9 @@ struct WorkspaceEditorFeature { return .none } } + .ifLet(\.$memberEditor, action: \.memberEditor) { + WorkspaceMemberEditorFeature() + } } static func defaultRepositoryName(for url: URL) -> String { @@ -809,50 +501,6 @@ struct WorkspaceEditorFeature { return String(name.dropLast(4)) } - nonisolated private static func loadRemoteBranchRefs( - _ url: String, - gitClient: GitClientDependency - ) async throws -> GitRemoteBranchRefs { - try await withThrowingTaskGroup(of: GitRemoteBranchRefs.self) { group in - group.addTask { - try await gitClient.remoteBranchRefs(url) - } - group.addTask { - try await Task.sleep(for: .seconds(30)) - throw RemoteBranchLoadTimeoutError() - } - guard let refs = try await group.next() else { - throw CancellationError() - } - group.cancelAll() - return refs - } - } - - nonisolated static func validationTarget( - for error: ProjectWorkspaceCreationError, - repositoryID: Repository.ID - ) -> ValidationTarget? { - switch error { - case .missingRepositoryName: - return .repository(repositoryID, .name) - case .missingRepositorySource: - return .repository(repositoryID, .source) - case .missingBranchName: - return .repository(repositoryID, .branchName) - case .missingExistingRef: - return .repository(repositoryID, .baseRef) - case .missingTitle: - return .title - case .missingPath: - return .rootPath - case .notEnoughRepositories, .linkCheckoutUnsupported, .destinationIsFile, - .workspaceAlreadyExists, .workspaceMetadataMissing, .repositoryDoesNotExist, - .linkAlreadyExists, .gitCommandFailed: - return nil - } - } - static func plan( for repository: ProjectWorkspaceCreationRepository ) -> Result { @@ -921,9 +569,3 @@ struct WorkspaceEditorFeature { ) } } - -private struct RemoteBranchLoadTimeoutError: LocalizedError { - var errorDescription: String? { - String(localized: "Remote branch loading timed out.") - } -} diff --git a/supacode/Features/Repositories/Reducer/WorkspaceMemberEditorFeature.swift b/supacode/Features/Repositories/Reducer/WorkspaceMemberEditorFeature.swift new file mode 100644 index 00000000..4165ec01 --- /dev/null +++ b/supacode/Features/Repositories/Reducer/WorkspaceMemberEditorFeature.swift @@ -0,0 +1,492 @@ +import ComposableArchitecture +import Foundation +import IdentifiedCollections + +/// The second level of the workspace editor: one repository at a time. +/// +/// Adding walks two steps — pick a source (opened repository, local folder, +/// remote URL), then configure name, role, and checkout. Editing a row added +/// in this session reopens step 2; editing an existing member only exposes +/// name, role, and the staged removal. +@Reducer +struct WorkspaceMemberEditorFeature { + private enum CancelID { + static let remoteLoad = "workspaceMemberEditor.remoteLoad" + static let baseRefs = "workspaceMemberEditor.baseRefs" + } + + enum Mode: Equatable, Sendable { + case add + case editAdded + case editExisting + } + + enum SourceChoice: Equatable, Sendable { + case opened + case local + case remote + } + + enum Field: Equatable, Sendable { + case remoteURL + case name + case branchName + case baseRef + } + + @ObservableState + struct State: Equatable { + var mode: Mode + var openedCandidates: IdentifiedArrayOf + /// Step 1 (add only). `nil` until the user picks a source. + var sourceChoice: SourceChoice? + var remoteURL = "" + var isLoadingRemote = false + var remoteErrorMessage: String? + /// Step 2 for added rows. + var draft: ProjectWorkspaceCreationRepository? + var isLoadingBaseRefs = false + /// Step 2 for existing members. + var existing: WorkspaceEditorExistingRepository? + var validationMessage: String? + var validationField: Field? + var isAdvancedExpanded = false + + var isConfiguring: Bool { + draft != nil || existing != nil + } + + static func add(openedCandidates: [ProjectWorkspaceCreationRepository]) -> State { + State( + mode: .add, + openedCandidates: IdentifiedArray(openedCandidates, uniquingIDsWith: { current, _ in current }) + ) + } + + static func editAdded(_ draft: ProjectWorkspaceCreationRepository) -> State { + State(mode: .editAdded, openedCandidates: [], draft: draft) + } + + static func editExisting(_ existing: WorkspaceEditorExistingRepository) -> State { + State(mode: .editExisting, openedCandidates: [], existing: existing) + } + + mutating func clearValidation() { + validationMessage = nil + validationField = nil + } + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case sourceChosen(SourceChoice) + case openedCandidateChosen(Repository.ID) + case localFolderChosen(String) + case remoteURLChanged(String) + case remoteURLCommitted + case remoteRefsLoaded(url: String, GitRemoteBranchRefs) + case remoteRefsFailed(url: String, String) + case baseRefsLoaded( + sourceLocation: String, options: [GitBranchRefOption], defaultBaseRef: String?, errorMessage: String?) + case nameChanged(String) + case roleChanged(String) + case pathChanged(String) + case checkoutModeChanged(ProjectWorkspaceRepositoryCheckoutMode) + case branchNameChanged(String) + case baseRefChanged(String) + case resetLocalBranchChanged(Bool) + case existingNameChanged(String) + case existingRoleChanged(String) + case existingRemovalChanged(Bool) + case existingDeleteFilesChanged(Bool) + case existingDeleteBranchChanged(Bool) + case backButtonTapped + case removeAddedButtonTapped + case commitButtonTapped + case delegate(Delegate) + } + + @CasePathable + enum Delegate: Equatable { + case cancel + case commitAdded(ProjectWorkspaceCreationRepository) + case commitExisting(WorkspaceEditorExistingRepository) + case removeAdded(Repository.ID) + } + + @Dependency(\.uuid) var uuid + @Dependency(\.continuousClock) var clock + @Dependency(GitClientDependency.self) var gitClient + + var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case .sourceChosen(let choice): + state.sourceChoice = choice + state.remoteErrorMessage = nil + state.clearValidation() + return .cancel(id: CancelID.remoteLoad) + + case .openedCandidateChosen(let repositoryID): + guard let candidate = state.openedCandidates[id: repositoryID] else { + return .none + } + state.draft = candidate + state.clearValidation() + return loadBaseRefs(for: candidate, state: &state) + + case .localFolderChosen(let path): + guard let rootPath = PathPolicy.normalizePath(path) else { + state.validationMessage = + ProjectWorkspaceCreationError.missingRepositorySource("repository").localizedDescription + return .none + } + let url = URL(fileURLWithPath: rootPath).standardizedFileURL + let draft = ProjectWorkspaceCreationRepository( + id: uuid().uuidString, + name: WorkspaceEditorFeature.defaultRepositoryName(for: url), + sourceKind: .localRepository, + sourceLocation: rootPath + ) + state.draft = draft + state.clearValidation() + return loadBaseRefs(for: draft, state: &state) + + case .remoteURLChanged(let url): + state.remoteURL = url + state.remoteErrorMessage = nil + state.clearValidation() + let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + state.isLoadingRemote = false + return .cancel(id: CancelID.remoteLoad) + } + // Branches load on their own once typing pauses; Return loads at once. + return .run { send in + try await clock.sleep(for: .milliseconds(800)) + await send(.remoteURLCommitted) + } + .cancellable(id: CancelID.remoteLoad, cancelInFlight: true) + + case .remoteURLCommitted: + let url = state.remoteURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !url.isEmpty else { + state.validationMessage = String(localized: "Remote URL required.") + state.validationField = .remoteURL + return .none + } + state.isLoadingRemote = true + state.remoteErrorMessage = nil + let gitClient = gitClient + return .run { send in + do { + let refs = try await Self.loadRemoteBranchRefs(url, gitClient: gitClient) + await send(.remoteRefsLoaded(url: url, refs)) + } catch { + await send(.remoteRefsFailed(url: url, error.localizedDescription)) + } + } + .cancellable(id: CancelID.remoteLoad, cancelInFlight: true) + + case .remoteRefsLoaded(let url, let refs): + guard state.remoteURL.trimmingCharacters(in: .whitespacesAndNewlines) == url else { + return .none + } + state.isLoadingRemote = false + let options = ProjectWorkspaceCreationRepository.normalizedBaseRefOptions(refs.options) + guard !options.isEmpty else { + state.remoteErrorMessage = String(localized: "No remote branches found.") + return .none + } + state.draft = ProjectWorkspaceCreationRepository( + id: uuid().uuidString, + name: GitRemoteNaming.repositoryName(fromRemoteURL: url), + sourceKind: .remote, + sourceLocation: url, + checkoutMode: .useExistingRef, + baseRef: ProjectWorkspaceCreationRepository.preferredBaseRef( + automaticBaseRef: refs.defaultBaseRef, options: options), + baseRefOptions: options + ) + return .none + + case .remoteRefsFailed(let url, let message): + guard state.remoteURL.trimmingCharacters(in: .whitespacesAndNewlines) == url else { + return .none + } + state.isLoadingRemote = false + state.remoteErrorMessage = message + return .none + + case .baseRefsLoaded(let sourceLocation, let options, let defaultBaseRef, let errorMessage): + guard var draft = state.draft, draft.sourceLocation == sourceLocation else { + return .none + } + state.isLoadingBaseRefs = false + if let errorMessage { + state.validationMessage = errorMessage + } + let baseRefOptions = ProjectWorkspaceCreationRepository.normalizedBaseRefOptions(options) + draft.baseRefOptions = baseRefOptions + if let baseRef = draft.baseRef?.trimmingCharacters(in: .whitespacesAndNewlines), + baseRefOptions.contains(where: { $0.ref == baseRef }) + { + draft.baseRef = baseRef + } else { + draft.baseRef = defaultBaseRef + } + state.draft = draft + return .none + + case .nameChanged(let name): + state.draft?.name = name + state.clearValidation() + return .none + + case .roleChanged(let role): + state.draft?.role = role + state.clearValidation() + return .none + + case .pathChanged(let path): + state.draft?.path = path + state.clearValidation() + return .none + + case .checkoutModeChanged(let checkoutMode): + guard var draft = state.draft, checkoutMode != .link || draft.sourceKind.supportsLinkCheckout + else { + return .none + } + draft.checkoutMode = checkoutMode + state.draft = draft + state.clearValidation() + return .none + + case .branchNameChanged(let branchName): + state.draft?.branchName = branchName + state.clearValidation() + return .none + + case .baseRefChanged(let baseRef): + guard var draft = state.draft else { + return .none + } + let trimmed = baseRef.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.isEmpty || draft.baseRefOptions.contains(where: { $0.ref == trimmed }) else { + return .none + } + draft.baseRef = trimmed.isEmpty ? nil : trimmed + // A new ref selection invalidates any previous keep/reset choice. + draft.resetLocalBranchToRemote = false + state.draft = draft + state.clearValidation() + return .none + + case .resetLocalBranchChanged(let resetToRemote): + state.draft?.resetLocalBranchToRemote = resetToRemote + return .none + + case .existingNameChanged(let name): + state.existing?.name = name + return .none + + case .existingRoleChanged(let role): + state.existing?.role = role + return .none + + case .existingRemovalChanged(let isRemoved): + guard state.existing != nil else { + return .none + } + state.existing?.removal = isRemoved ? .init() : nil + return .none + + case .existingDeleteFilesChanged(let deleteFiles): + guard var removal = state.existing?.removal else { + return .none + } + removal.deleteFiles = deleteFiles + if !deleteFiles { + removal.deleteBranch = false + } + state.existing?.removal = removal + return .none + + case .existingDeleteBranchChanged(let deleteBranch): + guard let existing = state.existing, var removal = existing.removal, + removal.deleteFiles || !deleteBranch, + existing.offersBranchDeletion || !deleteBranch + else { + return .none + } + removal.deleteBranch = deleteBranch + state.existing?.removal = removal + return .none + + case .backButtonTapped: + return .merge( + .cancel(id: CancelID.remoteLoad), + .cancel(id: CancelID.baseRefs), + .send(.delegate(.cancel)) + ) + + case .removeAddedButtonTapped: + guard state.mode == .editAdded, let draft = state.draft else { + return .none + } + return .send(.delegate(.removeAdded(draft.id))) + + case .commitButtonTapped: + if let existing = state.existing { + return .send(.delegate(.commitExisting(existing))) + } + guard let draft = state.draft else { + return .none + } + switch WorkspaceEditorFeature.plan(for: draft) { + case .success: + state.clearValidation() + return .merge( + .cancel(id: CancelID.baseRefs), + .send(.delegate(.commitAdded(draft))) + ) + case .failure(let error): + state.validationMessage = error.localizedDescription + state.validationField = Self.validationField(for: error) + return .none + } + + case .delegate: + return .none + } + } + } + + private func loadBaseRefs( + for draft: ProjectWorkspaceCreationRepository, + state: inout State + ) -> Effect { + state.isLoadingBaseRefs = true + let gitClient = gitClient + return .run { send in + let result = await Self.baseRefs(for: draft, gitClient: gitClient) + await send( + .baseRefsLoaded( + sourceLocation: draft.sourceLocation, + options: result.options, + defaultBaseRef: result.defaultBaseRef, + errorMessage: result.errorMessage + )) + } + .cancellable(id: CancelID.baseRefs, cancelInFlight: true) + } + + nonisolated static func validationField(for error: ProjectWorkspaceCreationError) -> Field? { + switch error { + case .missingRepositoryName: + return .name + case .missingBranchName: + return .branchName + case .missingExistingRef: + return .baseRef + case .missingRepositorySource, .missingTitle, .missingPath, .notEnoughRepositories, + .linkCheckoutUnsupported, .destinationIsFile, .workspaceAlreadyExists, + .workspaceMetadataMissing, .repositoryDoesNotExist, .linkAlreadyExists, .gitCommandFailed: + return nil + } + } + + nonisolated private static func loadRemoteBranchRefs( + _ url: String, + gitClient: GitClientDependency + ) async throws -> GitRemoteBranchRefs { + try await withThrowingTaskGroup(of: GitRemoteBranchRefs.self) { group in + group.addTask { + try await gitClient.remoteBranchRefs(url) + } + group.addTask { + try await Task.sleep(for: .seconds(30)) + throw RemoteBranchLoadTimeoutError() + } + guard let refs = try await group.next() else { + throw CancellationError() + } + group.cancelAll() + return refs + } + } + + nonisolated struct BaseRefsResult: Equatable, Sendable { + var options: [GitBranchRefOption] = [] + var defaultBaseRef: String? + var errorMessage: String? + } + + /// Local refs for an opened / local / bare source: the automatic base ref + /// (default branch) first, then every branch ref, grouped by kind. + nonisolated static func baseRefs( + for repository: ProjectWorkspaceCreationRepository, + gitClient: GitClientDependency + ) async -> BaseRefsResult { + switch repository.sourceKind { + case .remote: + return BaseRefsResult() + + case .existingPath, .localRepository, .bareRepository: + guard let sourceURL = repository.localSourceURL else { + return BaseRefsResult() + } + let repositoryURL: URL + if repository.sourceKind == .bareRepository { + repositoryURL = sourceURL + } else { + repositoryURL = (try? await gitClient.repoRoot(sourceURL)) ?? sourceURL + } + async let automaticBaseRefTask = gitClient.automaticWorktreeBaseRef(repositoryURL) + async let refsTask = gitClient.branchRefOptions(repositoryURL) + let automaticBaseRef = await automaticBaseRefTask + let refs: [GitBranchRefOption] + var errorMessage: String? + do { + refs = try await refsTask + } catch { + refs = [] + let name = repository.name.trimmingCharacters(in: .whitespacesAndNewlines) + let displayName = name.isEmpty ? repositoryURL.lastPathComponent : name + errorMessage = String( + localized: "Could not read branches for \(displayName): \(error.localizedDescription)" + ) + SupaLogger("workspace").warning( + "Branch detection failed for \(repositoryURL.path(percentEncoded: false)): \(error)" + ) + } + let options = ProjectWorkspaceCreationRepository.baseRefOptions( + automaticBaseRef: automaticBaseRef, + options: refs + ) + let defaultBaseRef = + automaticBaseRef != nil || !refs.isEmpty + ? ProjectWorkspaceCreationRepository.preferredBaseRef( + automaticBaseRef: automaticBaseRef, + options: options + ) + : nil + return BaseRefsResult( + options: options, + defaultBaseRef: defaultBaseRef, + errorMessage: errorMessage + ) + } + } +} + +private struct RemoteBranchLoadTimeoutError: LocalizedError { + var errorDescription: String? { + String(localized: "Remote branch loading timed out.") + } +} diff --git a/supacode/Features/Repositories/Views/WorkspaceEditorView.swift b/supacode/Features/Repositories/Views/WorkspaceEditorView.swift index f21a3b2f..6d9c9cfe 100644 --- a/supacode/Features/Repositories/Views/WorkspaceEditorView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceEditorView.swift @@ -2,41 +2,48 @@ import AppKit import ComposableArchitecture import SwiftUI -/// Creates a workspace or edits an existing one. Both modes share the same -/// form; edit mode adds the already-materialized members as read-only -/// provenance rows with name/role editing, reordering, and staged removal. +/// The workspace sheet. Level one (`WorkspacePanelView`) edits the workspace +/// metadata and lists its repositories; adding or editing one repository +/// swaps in `WorkspaceMemberEditorView` inside the same sheet. struct WorkspaceEditorView: View { @Bindable var store: StoreOf + + var body: some View { + Group { + if let memberStore = store.scope(state: \.memberEditor, action: \.memberEditor.presented) { + WorkspaceMemberEditorView(store: memberStore) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } else { + WorkspacePanelView(store: store) + .transition(.move(edge: .leading).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.2), value: store.memberEditor == nil) + .frame(width: 640) + .interactiveDismissDisabled(store.isSaving) + } +} + +private struct WorkspacePanelView: View { + @Bindable var store: StoreOf @FocusState private var isTitleFieldFocused: Bool var body: some View { VStack(alignment: .leading, spacing: 16) { - headerSection - // The reader wraps the scroll view so a validation error can bring the - // offending repository row into view. - ScrollViewReader { proxy in - ScrollView { - VStack(alignment: .leading, spacing: 16) { - titleSection - if !store.mode.isEditing { - folderSection - } - descriptionSection - taskLinksSection - repositoriesSection - } - .padding(.trailing, 4) - } - .onChange(of: store.validationRequestID) { _, _ in - guard let repositoryID = validationRepositoryID else { - return - } - withAnimation(.easeInOut(duration: 0.2)) { - proxy.scrollTo(repositoryID, anchor: .center) + header + ScrollView { + VStack(alignment: .leading, spacing: 16) { + titleSection + descriptionSection + taskLinksSection + if !store.mode.isEditing { + folderSection } + repositoriesSection } + .padding(.trailing, 4) } - .frame(maxHeight: 560) + .frame(maxHeight: 520) if let message = store.validationMessage, !message.isEmpty { Text(message) @@ -48,28 +55,12 @@ struct WorkspaceEditorView: View { footer } .padding(20) - .frame(minWidth: 720) .task { isTitleFieldFocused = true } - .interactiveDismissDisabled(store.isSaving) - .sheet( - isPresented: Binding( - get: { store.remoteRepositoryPrompt != nil }, - set: { isPresented in - if !isPresented { - store.send(.remoteRepositoryPromptDismissed) - } - } - ) - ) { - remoteRepositoryPromptView() - } } - // MARK: - Sections - - private var headerSection: some View { + private var header: some View { VStack(alignment: .leading, spacing: 4) { Text(store.mode.isEditing ? "Edit Workspace" : "New Workspace") .font(.title3) @@ -81,17 +72,21 @@ struct WorkspaceEditorView: View { .truncationMode(.middle) .textSelection(.enabled) } - Text(repositoryCountText) - .foregroundStyle(.secondary) + Text( + "A workspace gathers several repositories in one folder so an agent can work across them from a single root." + ) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } } private var titleSection: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { Text("Title") .foregroundStyle(.secondary) TextField( - "Workspace title", + "What this workspace is for, in a few words", text: Binding( get: { store.title }, set: { store.send(.titleChanged($0)) } @@ -106,198 +101,158 @@ struct WorkspaceEditorView: View { .onSubmit { store.send(.submitButtonTapped) } - helpText( - store.mode.isEditing - ? "Display name shown in the sidebar and workspace metadata. The folder on disk keeps its name." - : "A short name for the shared task folder shown in the sidebar and workspace metadata.") - } - } - - private var folderSection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Folder") - .foregroundStyle(.secondary) - HStack(spacing: 8) { - TextField( - "Workspace folder", - text: Binding( - get: { store.rootPath }, - set: { store.send(.rootPathChanged($0)) } - ) - ) - .textFieldStyle(.roundedBorder) - .font(.body.monospaced()) - .disabled(store.isSaving) - .overlay { - invalidFieldBorder(store.validationTarget == .rootPath) - } - Button { - chooseFolder() - } label: { - Label("Choose Folder", systemImage: "folder") - } - .help("Choose Workspace Folder") - .disabled(store.isSaving) - } - Text(store.rootPathPreview) - .font(.footnote.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.enabled) - helpText( - "Where Prowl creates the workspace root. Until you edit it, this path follows the workspace title." - ) } } private var descriptionSection: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { Text("Description") .foregroundStyle(.secondary) TextField( - "What this workspace is for", + "Optional. Shown to you and to agents working in this workspace.", text: Binding( get: { store.description }, set: { store.send(.descriptionChanged($0)) } ), axis: .vertical ) - .lineLimit(2...5) + .lineLimit(2...4) .textFieldStyle(.roundedBorder) .disabled(store.isSaving) - helpText("Optional task summary shown in the workspace detail view and available to agents.") } } private var taskLinksSection: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Task Links") - .foregroundStyle(.secondary) - Spacer() - Button { - store.send(.addTaskLinkButtonTapped) - } label: { - Label("Add Link", systemImage: "plus") + VStack(alignment: .leading, spacing: 6) { + Text("Task Links") + .foregroundStyle(.secondary) + TextField( + "Optional. One issue URL or identifier per line.", + text: Binding( + get: { store.taskLinksText }, + set: { store.send(.taskLinksTextChanged($0)) } + ), + axis: .vertical + ) + .lineLimit(1...4) + .textFieldStyle(.roundedBorder) + .font(.body.monospaced()) + .disabled(store.isSaving) + } + } + + private var folderSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Folder") + .foregroundStyle(.secondary) + HStack(spacing: 8) { + Text(store.rootPathPreview) + .font(.callout.monospaced()) + .foregroundStyle(store.validationTarget == .rootPath ? .red : .secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + Spacer(minLength: 8) + Button("Change…") { + chooseFolder() } .controlSize(.small) - .help("Add a task link or identifier") + .help("Choose a different folder for the workspace root") .disabled(store.isSaving) } - ForEach(store.taskLinks) { link in - HStack(spacing: 8) { - TextField( - "https://… or an issue key", - text: Binding( - get: { link.value }, - set: { store.send(.taskLinkChanged(link.id, $0)) } - ) - ) - .textFieldStyle(.roundedBorder) - .font(.body.monospaced()) - .disabled(store.isSaving) - Button { - store.send(.removeTaskLink(link.id)) - } label: { - Image(systemName: "minus.circle") - .accessibilityLabel("Remove Link") - } - .buttonStyle(.borderless) - .help("Remove Link") - .disabled(store.isSaving) - } - } - helpText("Optional issue URLs or identifiers for the work item. Empty rows are dropped on save.") + Text( + store.isRootPathDirty + ? "Prowl creates the workspace in this folder." + : "Follows the title until you change it. Prowl creates the workspace in this folder." + ) + .font(.footnote) + .foregroundStyle(.secondary) } } private var repositoriesSection: some View { VStack(alignment: .leading, spacing: 8) { - Text("Repositories") - .foregroundStyle(.secondary) - HStack(spacing: 8) { - Menu { - ForEach(store.availableOpenedRepositories) { repository in - Button { - store.send(.addOpenedRepository(repository.id)) - } label: { - Text(repository.name.isEmpty ? repository.sourceLocation : repository.name) - } - } - } label: { - Label("Add Opened", systemImage: "folder.badge.plus") - } - .help("Add Opened Path") - .disabled(store.isSaving || store.availableOpenedRepositories.isEmpty) - - Button { - store.send(.addRemoteButtonTapped) - } label: { - Label("Add Remote", systemImage: "network") - } - .help("Add Remote Repository") - .disabled(store.isSaving) - + HStack { + Text("Repositories") + .foregroundStyle(.secondary) + Spacer() Button { - chooseRepositorySource(kind: .localRepository) + store.send(.addRepositoryButtonTapped) } label: { - Label("Add Local", systemImage: "folder") + Label("Add Repository…", systemImage: "plus") } - .help("Add Local Repository") + .help("Add an opened, local, or remote repository to this workspace") .disabled(store.isSaving) } - helpText(repositoriesHelpText) let members = store.orderedMembers VStack(spacing: 0) { + if members.isEmpty { + emptyRepositoriesState + } ForEach(members) { member in - switch member { - case .existing(let repository): - existingRepositoryEditor(repository) - .id(repository.id) - case .added(let repository): - repositoryEditor(repository) - .id(repository.id) - } + WorkspaceMemberRowView( + member: member, + isEditing: store.mode.isEditing, + isFirst: member.id == members.first?.id, + isLast: member.id == members.last?.id, + isDisabled: store.isSaving, + onEdit: { store.send(.editMember(member.id)) }, + onRemove: { store.send(.removeMember(member.id)) }, + onUndo: { + if case .existing(let id) = member.id { + store.send(.undoRemoval(id)) + } + }, + onMoveUp: { store.send(.memberMovedUp(member.id)) }, + onMoveDown: { store.send(.memberMovedDown(member.id)) } + ) if member.id != members.last?.id { Divider() } } - if members.isEmpty { - Text("No repositories yet. Add one from the buttons above.") - .font(.callout) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 24) - } } .clipShape(.rect(cornerRadius: 8)) .overlay { RoundedRectangle(cornerRadius: 8) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + .stroke( + store.validationTarget == .repositories ? Color.red : Color(nsColor: .separatorColor), + lineWidth: store.validationTarget == .repositories ? 1.5 : 1) } } } - private var repositoriesHelpText: LocalizedStringKey { - if store.mode.isEditing { - return """ - Added repositories are materialized when you save. \ - Existing members keep their source and checkout; remove and re-add one to change them. - """ + private var emptyRepositoriesState: some View { + VStack(spacing: 6) { + Image(systemName: "folder.badge.plus") + .font(.title2) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text("No repositories yet") + .font(.callout.weight(.medium)) + Text( + "Add at least one. Each repository is linked, checked out on a branch, or cloned into the workspace folder." + ) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) } - return "Add at least one repository. Opened and local repositories can be linked or materialized as worktrees." + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .padding(.horizontal, 16) } private var footer: some View { - HStack { + HStack(alignment: .center, spacing: 12) { if store.isSaving { ProgressView() .controlSize(.small) - Text(store.mode.isEditing ? "Saving…" : "Creating…") - .font(.footnote) - .foregroundStyle(.secondary) } + Text(summaryText) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) Spacer() Button("Cancel") { store.send(.cancelButtonTapped) @@ -310,7 +265,7 @@ struct WorkspaceEditorView: View { } .keyboardShortcut(.defaultAction) .help(store.mode.isEditing ? "Save Workspace (↩)" : "Create Workspace (↩)") - .disabled(store.isSaving) + .disabled(store.isSaving || store.remainingRepositoryCount == 0) } } @@ -323,522 +278,70 @@ struct WorkspaceEditorView: View { return String(localized: "Cancel (Esc)") } - private var repositoryCountText: String { - let count = store.remainingRepositoryCount - var text = count == 1 ? String(localized: "1 repository") : String(localized: "\(count) repositories") - if store.hasPendingRemovals { - let removed = store.existingRepositories.filter(\.isMarkedForRemoval).count - text += - removed == 1 - ? String(localized: ", 1 to remove") : String(localized: ", \(removed) to remove") - } - return text - } - - // MARK: - Existing member rows - - private func existingRepositoryEditor(_ repository: WorkspaceEditorExistingRepository) -> some View { - VStack(alignment: .leading, spacing: 10) { - existingRepositoryHeader(repository) - if let removal = repository.removal { - existingRepositoryRemovalOptions(repository, removal: removal) - } else { - existingRepositoryFields(repository) - existingRepositoryProvenance(repository) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 10) - .background { - if repository.isMarkedForRemoval { - Color.red.opacity(0.06) - } - } - } - - private func existingRepositoryHeader(_ repository: WorkspaceEditorExistingRepository) -> some View { - HStack(spacing: 10) { - Text(repository.name.isEmpty ? repository.entry.name : repository.name) - .fontWeight(.medium) - .lineLimit(1) - .strikethrough(repository.isMarkedForRemoval) - sourceKindBadge(repository.entry.sourceKind) - if repository.isMarkedForRemoval { - Text("Removed on save") - .font(.caption) - .foregroundStyle(.red) - } - Spacer() - moveButtons(for: .existing(repository.id)) - if repository.isMarkedForRemoval { - Button("Undo") { - store.send(.existingRepositoryRemovalUndone(repository.id)) - } - .controlSize(.small) - .help("Keep this repository in the workspace") - .disabled(store.isSaving) - } else { - Button { - store.send(.existingRepositoryMarkedForRemoval(repository.id)) - } label: { - Image(systemName: "trash") - .accessibilityLabel("Remove from Workspace") - } - .buttonStyle(.borderless) - .help("Remove from Workspace on save") - .disabled(store.isSaving) - } - } - } - - /// Move Up / Move Down for any row; the order interleaves existing and - /// added members so a new repository can go anywhere in the list. - private func moveButtons(for key: WorkspaceEditorMemberKey) -> some View { - let keys = store.orderedMemberKeys - return HStack(spacing: 4) { - Button { - store.send(.memberMovedUp(key)) - } label: { - Image(systemName: "chevron.up") - .accessibilityLabel("Move Up") - } - .buttonStyle(.borderless) - .help("Move Up") - .disabled(store.isSaving || keys.first == key) - Button { - store.send(.memberMovedDown(key)) - } label: { - Image(systemName: "chevron.down") - .accessibilityLabel("Move Down") - } - .buttonStyle(.borderless) - .help("Move Down") - .disabled(store.isSaving || keys.last == key) - } - } - - private func existingRepositoryFields(_ repository: WorkspaceEditorExistingRepository) -> some View { - HStack(spacing: 8) { - VStack(alignment: .leading, spacing: 4) { - Text("Name") - .foregroundStyle(.secondary) - TextField( - "Repository name", - text: Binding( - get: { repository.name }, - set: { store.send(.existingRepositoryNameChanged(repository.id, $0)) } - ) - ) - .textFieldStyle(.roundedBorder) - .disabled(store.isSaving) - } - VStack(alignment: .leading, spacing: 4) { - Text("Role") - .foregroundStyle(.secondary) - TextField( - "app, backend, docs…", - text: Binding( - get: { repository.role }, - set: { store.send(.existingRepositoryRoleChanged(repository.id, $0)) } - ) - ) - .textFieldStyle(.roundedBorder) - .disabled(store.isSaving) - } - } - } - - private func existingRepositoryProvenance(_ repository: WorkspaceEditorExistingRepository) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(provenanceTitle(repository.entry)) - .font(.footnote) - .foregroundStyle(.secondary) - if let sourceLocation = repository.entry.sourceLocation, !sourceLocation.isEmpty { - Text(sourceLocation) - .font(.footnote.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.enabled) - } - } - } - - private func existingRepositoryRemovalOptions( - _ repository: WorkspaceEditorExistingRepository, - removal: WorkspaceEditorExistingRepository.Removal - ) -> some View { - VStack(alignment: .leading, spacing: 6) { - Toggle( - "Also delete the folder inside the workspace", - isOn: Binding( - get: { removal.deleteFiles }, - set: { store.send(.existingRepositoryDeleteFilesChanged(repository.id, $0)) } - ) - ) - .help(removalDeleteFilesHelp(repository.entry)) - .disabled(store.isSaving) - if repository.offersBranchDeletion, let branchName = repository.entry.branchName { - Toggle( - isOn: Binding( - get: { removal.deleteBranch }, - set: { store.send(.existingRepositoryDeleteBranchChanged(repository.id, $0)) } - ) - ) { - HStack(spacing: 4) { - Text("Delete branch") - Text(branchName) - .font(.body.monospaced()) - Text("in the source repository") - } - } - .padding(.leading, 20) - .help("Delete the branch with git branch -D after the worktree is removed. Protected branches are kept.") - .disabled(store.isSaving || !removal.deleteFiles) - } - helpText( - "The entry leaves the workspace metadata when you save. Files stay on disk unless you delete them here.") + /// One line that says what Create / Save is about to do. + private var summaryText: String { + if store.isSaving { + return store.mode.isEditing ? String(localized: "Saving…") : String(localized: "Creating…") } - } - - private func provenanceTitle(_ entry: ProjectWorkspaceRepositoryEntry) -> String { - let location = entry.path - switch entry.sourceKind { - case .remote: - if let baseRef = entry.branchName ?? entry.baseRef { - return String(localized: "Clone of \(baseRef) at \(location)") + let added = store.repositories.count + let removed = store.existingRepositories.filter(\.isMarkedForRemoval).count + let remaining = store.remainingRepositoryCount + if store.mode.isEditing { + guard added > 0 || removed > 0 else { + return remaining == 1 + ? String(localized: "Save keeps 1 repository.") + : String(localized: "Save keeps \(remaining) repositories.") } - return String(localized: "Clone at \(location)") - case .existingPath, .localRepository: - if let branchName = entry.branchName { - return String(localized: "Worktree on \(branchName) at \(location)") + var parts: [String] = [] + if added > 0 { + parts.append(added == 1 ? String(localized: "adds 1") : String(localized: "adds \(added)")) } - if let baseRef = entry.baseRef { - return String(localized: "Worktree on \(baseRef) at \(location)") + if removed > 0 { + parts.append( + removed == 1 ? String(localized: "removes 1") : String(localized: "removes \(removed)")) } - return String(localized: "Linked checkout at \(location)") - case .bareRepository: - let ref = entry.branchName ?? entry.baseRef ?? "HEAD" - return String(localized: "Worktree on \(ref) at \(location)") + let changes = parts.joined(separator: ", ") + return String(localized: "Save \(changes); \(remaining) repositories afterwards.") } - } - - private func removalDeleteFilesHelp(_ entry: ProjectWorkspaceRepositoryEntry) -> String { - switch entry.sourceKind { - case .remote: - return String(localized: "Deletes the cloned folder, including any uncommitted work in it.") - case .existingPath, .localRepository: - if entry.branchName == nil, entry.baseRef == nil { - return String(localized: "Removes only the symlink; the linked repository is untouched.") - } - return String( - localized: - "Unregisters the worktree from its source repository (git worktree remove --force) and deletes the folder.") - case .bareRepository: - return String( - localized: - "Unregisters the worktree from its source repository (git worktree remove --force) and deletes the folder.") + guard remaining > 0 else { + return String(localized: "Add a repository to create the workspace.") } + let kinds = Self.kindSummary(for: store.repositories) + let folder = URL(filePath: store.rootPathPreview).lastPathComponent + return remaining == 1 + ? String(localized: "Creates \(folder) with 1 repository (\(kinds)).") + : String(localized: "Creates \(folder) with \(remaining) repositories (\(kinds)).") } - // MARK: - New member rows - - private func repositoryEditor(_ repository: ProjectWorkspaceCreationRepository) -> some View { - VStack(alignment: .leading, spacing: 10) { - repositoryHeader(repository) - repositoryNameAndPathFields(repository) - repositorySourceField(repository) - repositoryBranchFields(repository) - } - .padding(.horizontal, 10) - .padding(.vertical, 10) - } - - private func repositoryHeader(_ repository: ProjectWorkspaceCreationRepository) -> some View { - HStack(spacing: 10) { - Text(repository.name.isEmpty ? String(localized: "Repository") : repository.name) - .fontWeight(.medium) - .lineLimit(1) - - sourceKindBadge(repository.sourceKind) - - if store.mode.isEditing { - Text("Added on save") - .font(.caption) - .foregroundStyle(.tint) - } - - Spacer() - - moveButtons(for: .added(repository.id)) - - Button { - store.send(.removeRepository(repository.id)) - } label: { - Image(systemName: "trash") - .accessibilityLabel("Remove Repository") - } - .buttonStyle(.borderless) - .help("Remove Repository") - .disabled(store.isSaving) - } - } - - private func repositoryNameAndPathFields(_ repository: ProjectWorkspaceCreationRepository) - -> some View + private static func kindSummary(for repositories: IdentifiedArrayOf) + -> String { - HStack(spacing: 8) { - VStack(alignment: .leading, spacing: 4) { - Text("Name") - .foregroundStyle(.secondary) - TextField( - "Repository name", - text: Binding( - get: { repository.name }, - set: { store.send(.repositoryNameChanged(repository.id, $0)) } - ) - ) - .textFieldStyle(.roundedBorder) - .disabled(store.isSaving) - .overlay { - invalidFieldBorder(repositoryFieldIsInvalid(repository, .name)) - } - helpText("Display name for this repository in the workspace metadata.") - } - - VStack(alignment: .leading, spacing: 4) { - Text("Role") - .foregroundStyle(.secondary) - TextField( - "app, backend, docs…", - text: Binding( - get: { repository.role ?? "" }, - set: { store.send(.repositoryRoleChanged(repository.id, $0)) } - ) - ) - .textFieldStyle(.roundedBorder) - .disabled(store.isSaving) - helpText("Optional short role that tells agents what this repository is for.") - } - - VStack(alignment: .leading, spacing: 4) { - Text("Folder inside workspace") - .foregroundStyle(.secondary) - TextField( - "Folder name", - text: Binding( - get: { repository.path ?? "" }, - set: { store.send(.repositoryPathChanged(repository.id, $0)) } - ) - ) - .textFieldStyle(.roundedBorder) - .disabled(store.isSaving) - helpText( - "Destination folder under the workspace root. It does not change the original source path." - ) + var linked = 0 + var worktrees = 0 + var clones = 0 + for repository in repositories { + if repository.sourceKind == .remote { + clones += 1 + } else if repository.checkoutMode == .link { + linked += 1 + } else { + worktrees += 1 } } - } - - private func repositorySourceField(_ repository: ProjectWorkspaceCreationRepository) -> some View { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - TextField( - sourceLocationPlaceholder(repository.sourceKind), - text: Binding( - get: { repository.sourceLocation }, - set: { store.send(.repositorySourceLocationChanged(repository.id, $0)) } - ) - ) - .textFieldStyle(.roundedBorder) - .font(.body.monospaced()) - .disabled(store.isSaving) - .overlay { - invalidFieldBorder(repositoryFieldIsInvalid(repository, .source)) - } - - if repository.sourceKind != .remote { - Button { - chooseSource(for: repository) - } label: { - Image(systemName: "folder") - .accessibilityLabel("Choose Repository Source") - } - .help("Choose Repository Source") - .disabled(store.isSaving) - } - } - helpText(sourceLocationHelpText(repository.sourceKind)) + var parts: [String] = [] + if linked > 0 { + parts.append(linked == 1 ? String(localized: "1 link") : String(localized: "\(linked) links")) } - } - - private func repositoryBranchFields(_ repository: ProjectWorkspaceCreationRepository) -> some View { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - Picker( - "Branch action", - selection: Binding( - get: { repository.checkoutMode }, - set: { store.send(.repositoryCheckoutModeChanged(repository.id, $0)) } - ) - ) { - if repository.sourceKind.supportsLinkCheckout { - Text("Link").tag(ProjectWorkspaceRepositoryCheckoutMode.link) - } - Text("Create Branch").tag(ProjectWorkspaceRepositoryCheckoutMode.createBranch) - Text("Use Existing").tag(ProjectWorkspaceRepositoryCheckoutMode.useExistingRef) - } - .pickerStyle(.menu) - .labelsHidden() - .frame(width: 150) - .help("Choose Branch Action") - .disabled(store.isSaving) - - if repository.checkoutMode == .createBranch { - TextField( - "Branch", - text: Binding( - get: { repository.branchName ?? "" }, - set: { store.send(.repositoryBranchNameChanged(repository.id, $0)) } - ) - ) - .textFieldStyle(.roundedBorder) - .disabled(store.isSaving) - .overlay { - invalidFieldBorder(repositoryFieldIsInvalid(repository, .branchName)) - } - } - - if repository.checkoutMode != .link { - WorkspaceBranchRefPickerView( - title: repository.checkoutMode == .createBranch - ? String(localized: "Base ref") - : String(localized: "Existing branch"), - selection: repository.baseRef, - options: repository.baseRefOptions, - isDisabled: store.isSaving || repository.baseRefOptions.isEmpty, - isInvalid: repositoryFieldIsInvalid(repository, .baseRef) - ) { ref in - store.send(.repositoryBaseRefChanged(repository.id, ref)) - } - .disabled(store.isSaving || repository.baseRefOptions.isEmpty) - } - } - - helpText(branchActionHelpText(repository)) - - if repository.checkoutMode != .link { - if let localBranchName = repository.resettableLocalBranchName { - VStack(alignment: .leading, spacing: 4) { - Text("Local branch “\(localBranchName)” already exists and would be reset to this ref.") - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - Picker( - "Local branch “\(localBranchName)”", - selection: Binding( - get: { repository.resetLocalBranchToRemote }, - set: { store.send(.repositoryResetLocalBranchChanged(repository.id, $0)) } - ) - ) { - Text("Use local branch").tag(false) - Text("Reset to \(repository.baseRef ?? "remote")").tag(true) - } - .pickerStyle(.radioGroup) - .labelsHidden() - .disabled(store.isSaving) - } - } - } + if worktrees > 0 { + parts.append( + worktrees == 1 ? String(localized: "1 worktree") : String(localized: "\(worktrees) worktrees")) } - } - - @ViewBuilder - private func remoteRepositoryPromptView() -> some View { - if let prompt = store.remoteRepositoryPrompt { - VStack(alignment: .leading, spacing: 16) { - Text("Add Remote Repository") - .font(.title3) - - VStack(alignment: .leading, spacing: 8) { - Text("Remote URL") - .foregroundStyle(.secondary) - TextField( - "git@github.com:owner/repo.git", - text: Binding( - get: { prompt.url }, - set: { store.send(.remoteRepositoryPromptURLChanged($0)) } - ) - ) - .textFieldStyle(.roundedBorder) - .font(.body.monospaced()) - .disabled(prompt.isLoading) - helpText("Remote git URL to clone into the workspace, such as SSH or HTTPS.") - } - - VStack(alignment: .leading, spacing: 8) { - Text("Name") - .foregroundStyle(.secondary) - TextField( - "Repository name", - text: Binding( - get: { prompt.name }, - set: { store.send(.remoteRepositoryPromptNameChanged($0)) } - ) - ) - .textFieldStyle(.roundedBorder) - .disabled(prompt.isLoading) - helpText("Display name and default folder name for this remote repository.") - } - - if !prompt.branchOptions.isEmpty { - Text("\(prompt.branchOptions.count) remote branches loaded") - .font(.footnote) - .foregroundStyle(.secondary) - } else { - helpText("Load branches before adding so Prowl can choose an existing branch safely.") - } - - if let message = prompt.validationMessage, !message.isEmpty { - Text(message) - .font(.footnote) - .foregroundStyle(.red) - } - - HStack { - if prompt.isLoading { - ProgressView() - .controlSize(.small) - } - Spacer() - Button("Cancel") { - store.send(.remoteRepositoryPromptDismissed) - } - .keyboardShortcut(.cancelAction) - .help("Cancel (Esc)") - - Button("Load") { - store.send(.remoteRepositoryPromptLoadButtonTapped) - } - .help("Load Remote Branches") - .disabled( - prompt.isLoading || prompt.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - - Button("Add") { - store.send(.remoteRepositoryPromptAddButtonTapped) - } - .keyboardShortcut(.defaultAction) - .help("Add Remote Repository") - .disabled(prompt.isLoading || prompt.branchOptions.isEmpty) - } - } - .padding(20) - .frame(width: 520) + if clones > 0 { + parts.append(clones == 1 ? String(localized: "1 clone") : String(localized: "\(clones) clones")) } + return parts.joined(separator: ", ") } - // MARK: - Panels - private func chooseFolder() { let panel = NSOpenPanel() panel.canChooseFiles = false @@ -855,284 +358,175 @@ struct WorkspaceEditorView: View { } } - private func chooseRepositorySource(kind: ProjectWorkspaceRepositorySourceKind) { - let panel = repositorySourcePanel(kind: kind, currentPath: nil) - panel.begin { response in - guard response == .OK, let url = panel.url else { - return - } - store.send(.addRepositoryFromURL(kind, url.path(percentEncoded: false))) - } - } - - private func chooseSource(for repository: ProjectWorkspaceCreationRepository) { - let panel = repositorySourcePanel( - kind: repository.sourceKind, currentPath: repository.sourceLocation) - panel.begin { response in - guard response == .OK, let url = panel.url else { - return - } - store.send(.repositorySourceChosen(repository.id, url.path(percentEncoded: false))) - } - } - - private func repositorySourcePanel( - kind: ProjectWorkspaceRepositorySourceKind, - currentPath: String? - ) -> NSOpenPanel { - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.canCreateDirectories = false - panel.allowsMultipleSelection = false - panel.prompt = String(localized: "Choose") - if let currentPath, !currentPath.isEmpty { - panel.directoryURL = URL(filePath: currentPath).deletingLastPathComponent() - } - panel.message = - kind == .bareRepository - ? String(localized: "Choose a bare repository folder") - : String(localized: "Choose a repository folder") - return panel + private func invalidFieldBorder(_ isInvalid: Bool) -> some View { + RoundedRectangle(cornerRadius: 5) + .stroke(isInvalid ? Color.red : Color.clear, lineWidth: isInvalid ? 1.5 : 0) + .allowsHitTesting(false) } +} - // MARK: - Copy helpers +/// One repository in the workspace list: name, role, and a plain sentence +/// saying what Create / Save does with it. +private struct WorkspaceMemberRowView: View { + let member: WorkspaceEditorMember + let isEditing: Bool + let isFirst: Bool + let isLast: Bool + let isDisabled: Bool + let onEdit: () -> Void + let onRemove: () -> Void + let onUndo: () -> Void + let onMoveUp: () -> Void + let onMoveDown: () -> Void - private func sourceKindTitle(_ kind: ProjectWorkspaceRepositorySourceKind) -> String { - switch kind { - case .existingPath: - return String(localized: "Opened in Prowl") - case .localRepository: - return String(localized: "Picked from Disk") - case .remote: - return String(localized: "Remote Clone") - case .bareRepository: - return String(localized: "Bare Worktree") - } - } + @State private var isHovered = false - private func sourceKindIcon(_ kind: ProjectWorkspaceRepositorySourceKind) -> String { - switch kind { - case .existingPath: - return "folder.badge.plus" - case .localRepository: - return "folder" - case .remote: - return "network" - case .bareRepository: - return "externaldrive" + var body: some View { + HStack(alignment: .center, spacing: 12) { + Image(systemName: iconName) + .foregroundStyle(.secondary) + .frame(width: 18) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(name) + .fontWeight(.medium) + .strikethrough(isMarkedForRemoval) + .lineLimit(1) + if let role, !role.isEmpty { + Text(role) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(1) + } + if let status = pendingStatus { + Text(status.text) + .font(.caption) + .foregroundStyle(status.isRemoval ? AnyShapeStyle(.red) : AnyShapeStyle(.tint)) + } + } + Text(summary) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer(minLength: 8) + HStack(spacing: 4) { + if isMarkedForRemoval { + Button("Undo", action: onUndo) + .controlSize(.small) + .help("Keep this repository in the workspace") + } else { + Button(action: onEdit) { + Image(systemName: "pencil") + .accessibilityLabel("Edit Repository") + } + .buttonStyle(.borderless) + .help("Edit this repository") + Button(action: onRemove) { + Image(systemName: "trash") + .accessibilityLabel("Remove Repository") + } + .buttonStyle(.borderless) + .help(isExisting ? "Remove from the workspace on save" : "Remove this repository") + } + } + .opacity(isHovered || isMarkedForRemoval ? 1 : 0.35) + .disabled(isDisabled) } - } - - private func sourceKindBadgeHelp(_ kind: ProjectWorkspaceRepositorySourceKind) -> String { - switch kind { - case .existingPath: - return String(localized: "Added from repositories already opened in Prowl.") - case .localRepository: - return String(localized: "Added by choosing a repository folder from disk.") - case .remote: - return String(localized: "Added from a remote URL and cloned into the workspace.") - case .bareRepository: - return String(localized: "Added from a local bare repository.") + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background { + if isMarkedForRemoval { + Color.red.opacity(0.06) + } else if isHovered { + Color.primary.opacity(0.04) + } + } + .contentShape(.rect) + .onHover { isHovered = $0 } + .contextMenu { + Button("Edit…", action: onEdit) + .disabled(isMarkedForRemoval) + Divider() + Button("Move Up", action: onMoveUp) + .disabled(isFirst) + Button("Move Down", action: onMoveDown) + .disabled(isLast) + Divider() + if isMarkedForRemoval { + Button("Undo Removal", action: onUndo) + } else { + Button("Remove", action: onRemove) + } } + .help(summary) } - private func sourceKindBadge(_ kind: ProjectWorkspaceRepositorySourceKind) -> some View { - Label(sourceKindTitle(kind), systemImage: sourceKindIcon(kind)) - .font(.caption) - .foregroundStyle(.secondary) - .labelStyle(.titleAndIcon) - .lineLimit(1) - .help(sourceKindBadgeHelp(kind)) + private var isExisting: Bool { + if case .existing = member { return true } + return false } - private func sourceLocationPlaceholder(_ kind: ProjectWorkspaceRepositorySourceKind) -> String { - switch kind { - case .existingPath, .localRepository: - return String(localized: "Repository folder") - case .remote: - return String(localized: "Remote URL") - case .bareRepository: - return String(localized: "Bare repository folder") - } + private var isMarkedForRemoval: Bool { + if case .existing(let repository) = member { return repository.isMarkedForRemoval } + return false } - private func sourceLocationHelpText(_ kind: ProjectWorkspaceRepositorySourceKind) -> LocalizedStringKey { - switch kind { - case .existingPath: - return - """ - Existing opened repository path. Link keeps using this checkout; \ - branch actions create workspace worktrees from it. - """ - case .localRepository: - return - "Local repository folder on disk. It can be linked as-is or used as the source for a workspace worktree." - case .remote: - return "Remote URL cloned into the workspace folder after branches are loaded." - case .bareRepository: - return "Advanced source: a local bare repository used only for git worktree materialization." + private var name: String { + switch member { + case .existing(let repository): + let trimmed = repository.name.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? repository.entry.name : trimmed + case .added(let repository): + return repository.name.isEmpty ? String(localized: "Repository") : repository.name } } - private func branchActionHelpText(_ repository: ProjectWorkspaceCreationRepository) -> LocalizedStringKey { - switch repository.checkoutMode { - case .link: - return - "Link adds a symlink to the source checkout, so workspace edits affect the original folder directly." - case .createBranch: - return - "Create Branch materializes an isolated checkout on a new branch from the selected base ref." - case .useExistingRef: - if repository.resettableLocalBranchName != nil { - return - """ - Use Existing checks out the selected branch. \ - If a matching local branch already exists, choose whether to keep or reset it. - """ - } - return - "Use Existing checks out the selected local branch or creates a local tracking branch from a remote ref." + private var role: String? { + switch member { + case .existing(let repository): + return repository.role + case .added(let repository): + return repository.role } } - private func helpText(_ text: LocalizedStringKey) -> some View { - Text(text) - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - private var validationRepositoryID: String? { - switch store.validationTarget { - case .repository(let repositoryID, _): - return repositoryID - case .existingRepository(let entryID): - return entryID - case .title, .rootPath, nil: - return nil - } - } - - private func repositoryFieldIsInvalid( - _ repository: ProjectWorkspaceCreationRepository, - _ field: WorkspaceEditorFeature.RepositoryField - ) -> Bool { - store.validationTarget == .repository(repository.id, field) - } - - private func invalidFieldBorder(_ isInvalid: Bool) -> some View { - RoundedRectangle(cornerRadius: 5) - .stroke(isInvalid ? Color.red : Color.clear, lineWidth: isInvalid ? 1.5 : 0) - .allowsHitTesting(false) - } -} - -private struct WorkspaceBranchRefPickerView: View { - let title: String - let selection: String? - let options: [GitBranchRefOption] - let isDisabled: Bool - let isInvalid: Bool - let onSelect: (String) -> Void - - @State private var isPresented = false - @State private var searchText = "" - - var body: some View { - Button { - isPresented = true - } label: { - HStack { - Text(displayTitle) - .lineLimit(1) - .truncationMode(.middle) - Spacer(minLength: 8) - Image(systemName: "chevron.down") - .imageScale(.small) - .accessibilityHidden(true) + private var iconName: String { + switch member { + case .existing(let repository): + switch repository.entry.sourceKind { + case .remote: + return "network" + case .existingPath, .localRepository, .bareRepository: + return repository.entry.branchName == nil && repository.entry.baseRef == nil + ? "link" : "arrow.triangle.branch" } - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.bordered) - .overlay { - invalidFieldBorder - } - .help("Choose \(title)") - .disabled(isDisabled) - .popover(isPresented: $isPresented, arrowEdge: .bottom) { - VStack(alignment: .leading, spacing: 10) { - TextField("Search branches", text: $searchText) - .textFieldStyle(.roundedBorder) - - ScrollView { - VStack(alignment: .leading, spacing: 10) { - ForEach(groupedOptions, id: \.kind) { group in - VStack(alignment: .leading, spacing: 4) { - Text(group.kind.title) - .font(.caption) - .foregroundStyle(.secondary) - ForEach(group.options) { option in - Button { - onSelect(option.ref) - isPresented = false - searchText = "" - } label: { - HStack { - if option.ref == selection { - Image(systemName: "checkmark") - .frame(width: 14) - .accessibilityHidden(true) - } else { - Color.clear - .frame(width: 14, height: 1) - } - Text(option.ref) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - } - } - .buttonStyle(.plain) - .padding(.vertical, 3) - } - } - } - if groupedOptions.isEmpty { - Text("No matching branches") - .foregroundStyle(.secondary) - } - } - } - .frame(maxHeight: 260) + case .added(let repository): + if repository.sourceKind == .remote { + return "network" } - .padding(14) - .frame(width: 420) + return repository.checkoutMode == .link ? "link" : "arrow.triangle.branch" } } - private var displayTitle: String { - guard let selection, !selection.isEmpty else { - return title + private var pendingStatus: (text: String, isRemoval: Bool)? { + switch member { + case .existing(let repository): + return repository.isMarkedForRemoval ? (String(localized: "Removed on save"), true) : nil + case .added: + return isEditing ? (String(localized: "Added on save"), false) : nil } - return selection - } - - private var invalidFieldBorder: some View { - RoundedRectangle(cornerRadius: 5) - .stroke(isInvalid ? Color.red : Color.clear, lineWidth: isInvalid ? 1.5 : 0) - .allowsHitTesting(false) } - private var groupedOptions: [(kind: GitBranchRefKind, options: [GitBranchRefOption])] { - let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) - let filtered = - query.isEmpty - ? options - : options.filter { $0.ref.localizedCaseInsensitiveContains(query) } - return GitBranchRefKind.allCases.compactMap { kind in - let group = filtered.filter { $0.kind == kind } - return group.isEmpty ? nil : (kind, group) + /// What Create / Save does with this row, in one sentence. + private var summary: String { + switch member { + case .existing(let repository): + return WorkspaceMemberSummary.existing(repository.entry) + case .added(let repository): + return WorkspaceMemberSummary.added(repository) } } } diff --git a/supacode/Features/Repositories/Views/WorkspaceMemberEditorView.swift b/supacode/Features/Repositories/Views/WorkspaceMemberEditorView.swift new file mode 100644 index 00000000..b9e1fcc4 --- /dev/null +++ b/supacode/Features/Repositories/Views/WorkspaceMemberEditorView.swift @@ -0,0 +1,733 @@ +import AppKit +import ComposableArchitecture +import SwiftUI + +/// Level two of the workspace sheet: one repository. Adding walks source +/// choice → configuration; editing an existing member shows name, role, +/// and the staged removal. +struct WorkspaceMemberEditorView: View { + @Bindable var store: StoreOf + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + header + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if store.existing != nil { + existingSections + } else if let draft = store.draft { + configureSections(draft) + } else { + sourceSections + } + } + .padding(.trailing, 4) + } + .frame(maxHeight: 520) + + if let message = store.validationMessage, !message.isEmpty { + Text(message) + .font(.footnote) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + + footer + } + .padding(20) + } + + // MARK: - Header / footer + + private var header: some View { + HStack(spacing: 10) { + Button { + store.send(.backButtonTapped) + } label: { + Label("Back", systemImage: "chevron.left") + } + .help("Back to the workspace") + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.title3) + if let subtitle { + Text(subtitle) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + } + + private var title: LocalizedStringKey { + switch store.mode { + case .add: + return "Add Repository" + case .editAdded, .editExisting: + return "Edit Repository" + } + } + + private var subtitle: String? { + if let existing = store.existing { + return existing.entry.sourceLocation ?? existing.entry.path + } + if let draft = store.draft { + return draft.sourceLocation + } + return nil + } + + private var footer: some View { + HStack { + if store.mode == .editAdded { + Button("Remove Repository", role: .destructive) { + store.send(.removeAddedButtonTapped) + } + .help("Remove this repository from the workspace before it is created") + } + Spacer() + Button("Cancel") { + store.send(.backButtonTapped) + } + .keyboardShortcut(.cancelAction) + .help("Discard changes to this repository (Esc)") + Button(commitTitle) { + store.send(.commitButtonTapped) + } + .keyboardShortcut(.defaultAction) + .help(commitHelp) + .disabled(!store.isConfiguring || store.isLoadingBaseRefs) + } + } + + private var commitTitle: LocalizedStringKey { + store.mode == .add ? "Add" : "Done" + } + + private var commitHelp: String { + store.mode == .add + ? String(localized: "Add this repository to the workspace list (↩)") + : String(localized: "Apply changes to this repository (↩)") + } + + // MARK: - Step 1: source + + private var sourceSections: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Where does the repository come from?") + .foregroundStyle(.secondary) + sourceOption( + .opened, + title: "Opened in Prowl", + detail: "Pick one of the repositories already in the sidebar.", + systemImage: "folder.badge.plus", + isEnabled: !store.openedCandidates.isEmpty + ) + if store.sourceChoice == .opened { + openedCandidateList + } + sourceOption( + .local, + title: "Local Folder…", + detail: "Choose a git repository on disk.", + systemImage: "folder", + isEnabled: true + ) + sourceOption( + .remote, + title: "Remote URL", + detail: "Clone from an SSH or HTTPS git URL into the workspace.", + systemImage: "network", + isEnabled: true + ) + if store.sourceChoice == .remote { + remoteURLField + } + } + } + + private func sourceOption( + _ choice: WorkspaceMemberEditorFeature.SourceChoice, + title: LocalizedStringKey, + detail: LocalizedStringKey, + systemImage: String, + isEnabled: Bool + ) -> some View { + let isSelected = store.sourceChoice == choice + return Button { + if choice == .local { + chooseLocalFolder() + } else { + store.send(.sourceChosen(choice)) + } + } label: { + HStack(spacing: 12) { + Image(systemName: systemImage) + .font(.title3) + .frame(width: 24) + .accessibilityHidden(true) + .foregroundStyle(isSelected ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .fontWeight(.medium) + Text(detail) + .font(.footnote) + .foregroundStyle(.secondary) + } + Spacer() + if isSelected { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + .accessibilityHidden(true) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isSelected ? Color.accentColor.opacity(0.12) : Color.primary.opacity(0.04)) + ) + .contentShape(.rect) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + .help(isEnabled ? "" : "Every opened repository is already in this workspace") + } + + private var openedCandidateList: some View { + VStack(spacing: 0) { + ForEach(store.openedCandidates) { candidate in + Button { + store.send(.openedCandidateChosen(candidate.id)) + } label: { + HStack { + Text(candidate.name.isEmpty ? candidate.sourceLocation : candidate.name) + Spacer() + Text(candidate.sourceLocation) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(.rect) + } + .buttonStyle(.plain) + .help("Add \(candidate.name)") + if candidate.id != store.openedCandidates.last?.id { + Divider() + } + } + } + .clipShape(.rect(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + .padding(.leading, 36) + } + + private var remoteURLField: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + TextField( + "git@github.com:owner/repo.git", + text: Binding( + get: { store.remoteURL }, + set: { store.send(.remoteURLChanged($0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .font(.body.monospaced()) + .overlay { + invalidFieldBorder(store.validationField == .remoteURL) + } + .onSubmit { + store.send(.remoteURLCommitted) + } + if store.isLoadingRemote { + ProgressView() + .controlSize(.small) + } + } + if let message = store.remoteErrorMessage { + Text(message) + .font(.footnote) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } else { + Text(store.isLoadingRemote ? "Loading branches…" : "Branches load as soon as you finish typing.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(.leading, 36) + } + + // MARK: - Step 2: configure an added repository + + private func configureSections(_ draft: ProjectWorkspaceCreationRepository) -> some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 6) { + Text("Name") + .foregroundStyle(.secondary) + TextField( + "Repository name", + text: Binding( + get: { draft.name }, + set: { store.send(.nameChanged($0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .overlay { + invalidFieldBorder(store.validationField == .name) + } + } + VStack(alignment: .leading, spacing: 6) { + Text("Role") + .foregroundStyle(.secondary) + TextField( + "Optional: app, backend, docs…", + text: Binding( + get: { draft.role ?? "" }, + set: { store.send(.roleChanged($0)) } + ) + ) + .textFieldStyle(.roundedBorder) + } + } + + checkoutSection(draft) + + DisclosureGroup("Advanced", isExpanded: $store.isAdvancedExpanded) { + VStack(alignment: .leading, spacing: 6) { + Text("Folder inside workspace") + .foregroundStyle(.secondary) + TextField( + Self.defaultFolderName(for: draft), + text: Binding( + get: { draft.path ?? "" }, + set: { store.send(.pathChanged($0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .font(.body.monospaced()) + Text("Defaults to the repository name. Prowl adds a suffix if the folder already exists.") + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(.top, 6) + } + } + } + + private func checkoutSection(_ draft: ProjectWorkspaceCreationRepository) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text("Checkout") + .foregroundStyle(.secondary) + Picker( + "Checkout", + selection: Binding( + get: { draft.checkoutMode }, + set: { store.send(.checkoutModeChanged($0)) } + ) + ) { + if draft.sourceKind.supportsLinkCheckout { + Text("Link").tag(ProjectWorkspaceRepositoryCheckoutMode.link) + } + Text("New Branch").tag(ProjectWorkspaceRepositoryCheckoutMode.createBranch) + Text("Existing Branch").tag(ProjectWorkspaceRepositoryCheckoutMode.useExistingRef) + } + .pickerStyle(.segmented) + .labelsHidden() + .help("How this repository is placed in the workspace") + + if draft.checkoutMode != .link { + HStack(spacing: 8) { + if draft.checkoutMode == .createBranch { + TextField( + "New branch name", + text: Binding( + get: { draft.branchName ?? "" }, + set: { store.send(.branchNameChanged($0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .font(.body.monospaced()) + .overlay { + invalidFieldBorder(store.validationField == .branchName) + } + Text("from") + .foregroundStyle(.secondary) + } + WorkspaceBranchRefPickerView( + title: draft.checkoutMode == .createBranch + ? String(localized: "Base branch") : String(localized: "Branch"), + selection: draft.baseRef, + options: draft.baseRefOptions, + isDisabled: store.isLoadingBaseRefs || draft.baseRefOptions.isEmpty, + isInvalid: store.validationField == .baseRef + ) { ref in + store.send(.baseRefChanged(ref)) + } + if store.isLoadingBaseRefs { + ProgressView() + .controlSize(.small) + } + } + } + Text(checkoutHelp(draft)) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if draft.checkoutMode != .link, let localBranchName = draft.resettableLocalBranchName { + VStack(alignment: .leading, spacing: 4) { + Text("Local branch “\(localBranchName)” already exists and would be reset to this ref.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Picker( + "Local branch “\(localBranchName)”", + selection: Binding( + get: { draft.resetLocalBranchToRemote }, + set: { store.send(.resetLocalBranchChanged($0)) } + ) + ) { + Text("Use local branch").tag(false) + Text("Reset to \(draft.baseRef ?? "remote")").tag(true) + } + .pickerStyle(.radioGroup) + .labelsHidden() + } + } + } + } + + private func checkoutHelp(_ draft: ProjectWorkspaceCreationRepository) -> LocalizedStringKey { + if draft.sourceKind == .remote { + switch draft.checkoutMode { + case .link, .useExistingRef: + return "Clones the repository into the workspace and checks out the selected branch." + case .createBranch: + return "Clones the repository into the workspace and creates the new branch from the selected base." + } + } + switch draft.checkoutMode { + case .link: + return "Adds a symlink to the source checkout. Edits in the workspace change the original folder directly." + case .createBranch: + return "Creates an isolated worktree on a new branch. The source checkout is left untouched." + case .useExistingRef: + if draft.resettableLocalBranchName != nil { + return """ + Creates a worktree on the selected branch. \ + A matching local branch already exists; choose whether to keep or reset it. + """ + } + return "Creates a worktree on the selected branch, or a local tracking branch for a remote ref." + } + } + + static func defaultFolderName(for draft: ProjectWorkspaceCreationRepository) -> String { + let name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? String(localized: "Folder name") : name + } + + // MARK: - Existing member + + private var existingSections: some View { + VStack(alignment: .leading, spacing: 16) { + if let existing = store.existing { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 6) { + Text("Name") + .foregroundStyle(.secondary) + TextField( + existing.entry.name, + text: Binding( + get: { existing.name }, + set: { store.send(.existingNameChanged($0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .disabled(existing.isMarkedForRemoval) + } + VStack(alignment: .leading, spacing: 6) { + Text("Role") + .foregroundStyle(.secondary) + TextField( + "Optional: app, backend, docs…", + text: Binding( + get: { existing.role }, + set: { store.send(.existingRoleChanged($0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .disabled(existing.isMarkedForRemoval) + } + } + + VStack(alignment: .leading, spacing: 4) { + Text("Checkout") + .foregroundStyle(.secondary) + Text(WorkspaceMemberSummary.existing(existing.entry)) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + Text( + """ + Source and checkout are fixed once a repository is in the workspace. \ + Remove and add it again to change them. + """ + ) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(alignment: .leading, spacing: 8) { + Toggle( + "Remove from Workspace on save", + isOn: Binding( + get: { existing.isMarkedForRemoval }, + set: { store.send(.existingRemovalChanged($0)) } + ) + ) + .tint(.red) + .help("The entry leaves the workspace metadata when you save.") + if let removal = existing.removal { + Toggle( + "Also delete the folder inside the workspace", + isOn: Binding( + get: { removal.deleteFiles }, + set: { store.send(.existingDeleteFilesChanged($0)) } + ) + ) + .padding(.leading, 20) + .help(deleteFilesHelp(existing.entry)) + if existing.offersBranchDeletion, let branchName = existing.entry.branchName { + Toggle( + isOn: Binding( + get: { removal.deleteBranch }, + set: { store.send(.existingDeleteBranchChanged($0)) } + ) + ) { + HStack(spacing: 4) { + Text("Delete branch") + Text(branchName) + .font(.body.monospaced()) + Text("in the source repository") + } + } + .padding(.leading, 20) + .disabled(!removal.deleteFiles) + .help("Runs git branch -D after the worktree is removed. Protected branches are kept.") + } + Text("Files stay on disk unless you delete them here.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + } + } + + private func deleteFilesHelp(_ entry: ProjectWorkspaceRepositoryEntry) -> String { + switch entry.sourceKind { + case .remote: + return String(localized: "Deletes the cloned folder, including any uncommitted work in it.") + case .existingPath, .localRepository: + if entry.branchName == nil, entry.baseRef == nil { + return String(localized: "Removes only the symlink; the linked repository is untouched.") + } + return String(localized: "Unregisters the worktree from its source repository and deletes the folder.") + case .bareRepository: + return String(localized: "Unregisters the worktree from its source repository and deletes the folder.") + } + } + + // MARK: - Helpers + + private func chooseLocalFolder() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = false + panel.allowsMultipleSelection = false + panel.prompt = String(localized: "Choose") + panel.message = String(localized: "Choose a repository folder") + panel.begin { response in + guard response == .OK, let url = panel.url else { + return + } + store.send(.localFolderChosen(url.path(percentEncoded: false))) + } + } + + private func invalidFieldBorder(_ isInvalid: Bool) -> some View { + RoundedRectangle(cornerRadius: 5) + .stroke(isInvalid ? Color.red : Color.clear, lineWidth: isInvalid ? 1.5 : 0) + .allowsHitTesting(false) + } +} + +/// Plain-language summaries shared by the list rows and the member editor. +enum WorkspaceMemberSummary { + static func existing(_ entry: ProjectWorkspaceRepositoryEntry) -> String { + let location = entry.sourceLocation ?? entry.path + switch entry.sourceKind { + case .remote: + if let ref = entry.branchName ?? entry.baseRef { + return String(localized: "Clone of \(location) @ \(ref)") + } + return String(localized: "Clone of \(location)") + case .existingPath, .localRepository: + if let branchName = entry.branchName { + return String(localized: "Worktree on \(branchName) from \(location)") + } + if let baseRef = entry.baseRef { + return String(localized: "Worktree on \(baseRef) from \(location)") + } + return String(localized: "Link → shares the live checkout at \(location)") + case .bareRepository: + let ref = entry.branchName ?? entry.baseRef ?? "HEAD" + return String(localized: "Worktree on \(ref) from \(location)") + } + } + + static func added(_ repository: ProjectWorkspaceCreationRepository) -> String { + let location = repository.sourceLocation + if repository.sourceKind == .remote { + if repository.checkoutMode == .createBranch, let branch = repository.branchName, !branch.isEmpty { + return String(localized: "Clone \(location), new branch \(branch)") + } + if let ref = repository.baseRef { + return String(localized: "Clone \(location) @ \(ref)") + } + return String(localized: "Clone \(location)") + } + switch repository.checkoutMode { + case .link: + return String(localized: "Link → shares the live checkout at \(location)") + case .createBranch: + let branch = repository.branchName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let base = repository.baseRef ?? "HEAD" + return branch.isEmpty + ? String(localized: "New branch from \(base) → worktree") + : String(localized: "New branch \(branch) from \(base) → worktree") + case .useExistingRef: + let ref = repository.baseRef ?? "?" + return String(localized: "Existing branch \(ref) → worktree") + } + } +} + +struct WorkspaceBranchRefPickerView: View { + let title: String + let selection: String? + let options: [GitBranchRefOption] + let isDisabled: Bool + let isInvalid: Bool + let onSelect: (String) -> Void + + @State private var isPresented = false + @State private var searchText = "" + + var body: some View { + Button { + isPresented = true + } label: { + HStack { + Text(displayTitle) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 8) + Image(systemName: "chevron.down") + .imageScale(.small) + .accessibilityHidden(true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.bordered) + .overlay { + RoundedRectangle(cornerRadius: 5) + .stroke(isInvalid ? Color.red : Color.clear, lineWidth: isInvalid ? 1.5 : 0) + .allowsHitTesting(false) + } + .help("Choose \(title)") + .disabled(isDisabled) + .popover(isPresented: $isPresented, arrowEdge: .bottom) { + VStack(alignment: .leading, spacing: 10) { + TextField("Search branches", text: $searchText) + .textFieldStyle(.roundedBorder) + + ScrollView { + VStack(alignment: .leading, spacing: 10) { + ForEach(groupedOptions, id: \.kind) { group in + VStack(alignment: .leading, spacing: 4) { + Text(group.kind.title) + .font(.caption) + .foregroundStyle(.secondary) + ForEach(group.options) { option in + Button { + onSelect(option.ref) + isPresented = false + searchText = "" + } label: { + HStack { + if option.ref == selection { + Image(systemName: "checkmark") + .frame(width: 14) + .accessibilityHidden(true) + } else { + Color.clear + .frame(width: 14, height: 1) + } + Text(option.ref) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + } + } + .buttonStyle(.plain) + .padding(.vertical, 3) + } + } + } + if groupedOptions.isEmpty { + Text("No matching branches") + .foregroundStyle(.secondary) + } + } + } + .frame(maxHeight: 260) + } + .padding(14) + .frame(width: 420) + } + } + + private var displayTitle: String { + guard let selection, !selection.isEmpty else { + return title + } + return selection + } + + private var groupedOptions: [(kind: GitBranchRefKind, options: [GitBranchRefOption])] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + let filtered = + query.isEmpty + ? options + : options.filter { $0.ref.localizedCaseInsensitiveContains(query) } + return GitBranchRefKind.allCases.compactMap { kind in + let group = filtered.filter { $0.kind == kind } + return group.isEmpty ? nil : (kind, group) + } + } +} diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 9a25502e..a72a59e9 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -772,613 +772,6 @@ struct RepositoriesFeatureTests { } } - @Test func workspaceEditorAddsOpenedRepositoryOnRequest() async { - let repoRootA = "/tmp/repo-a" - let candidate = ProjectWorkspaceCreationRepository( - id: repoRootA, - name: "Repo A", - rootURL: URL(fileURLWithPath: repoRootA) - ) - let store = TestStore( - initialState: WorkspaceEditorFeature.State( - repositories: [], - title: "Workspace", - rootPath: "/tmp/workspace", - openedRepositoryCandidates: [candidate] - ) - ) { - WorkspaceEditorFeature() - } - - await store.send(.addOpenedRepository(repoRootA)) { - $0.repositories = [candidate] - } - await store.receive(.delegate(.baseRefSourceChanged(repoRootA))) - } - - @Test func resetLocalBranchChoiceTogglesAndClearsOnRefChange() async { - let repoID = "/tmp/repo-a" - let repo = ProjectWorkspaceCreationRepository( - id: repoID, - name: "Repo A", - sourceKind: .bareRepository, - sourceLocation: "/tmp/repo-a.git", - checkoutMode: .useExistingRef, - baseRef: "origin/feature", - baseRefOptions: [ - GitBranchRefOption(ref: "origin/feature", kind: .remoteTracking), - GitBranchRefOption(ref: "feature", kind: .local), - ] - ) - let store = TestStore( - initialState: WorkspaceEditorFeature.State( - repositories: [repo], - title: "Workspace", - rootPath: "/tmp/workspace" - ) - ) { - WorkspaceEditorFeature() - } - - await store.send(.repositoryResetLocalBranchChanged(repoID, true)) { - $0.repositories[id: repoID]?.resetLocalBranchToRemote = true - } - // Selecting a different ref invalidates the keep/reset choice. - await store.send(.repositoryBaseRefChanged(repoID, "feature")) { - $0.repositories[id: repoID]?.baseRef = "feature" - $0.repositories[id: repoID]?.resetLocalBranchToRemote = false - } - } - - @Test func workspaceEditorIgnoresDuplicateOpenedRepository() async { - let repoRootA = "/tmp/repo-a" - let candidate = ProjectWorkspaceCreationRepository( - id: repoRootA, - name: "Repo A", - rootURL: URL(fileURLWithPath: repoRootA) - ) - let store = TestStore( - initialState: WorkspaceEditorFeature.State( - repositories: [candidate], - title: "Workspace", - rootPath: "/tmp/workspace", - openedRepositoryCandidates: [candidate] - ) - ) { - WorkspaceEditorFeature() - } - - await store.send(.addOpenedRepository(repoRootA)) - } - - @Test func workspaceEditorOffersLoadedRepositories() async { - let testID = UUID().uuidString - let repoRootA = "/tmp/\(testID)-repo-a" - let repoRootB = "/tmp/\(testID)-repo-b" - let worktreeA = makeWorktree(id: repoRootA, name: "main", repoRoot: repoRootA) - let repoA = makeRepository(id: repoRootA, name: "Repo A", worktrees: [worktreeA]) - let repoB = makeRepository(id: repoRootB, name: "Repo B", worktrees: []) - var state = makeState(repositories: [repoA, repoB]) - state.repositoryCustomTitles[repoB.id] = "API" - let store = TestStore(initialState: state) { - RepositoriesFeature() - } - - let title = "Workspace" - let requestedRootPath = defaultWorkspaceBaseRootPath(for: title) - let resolvedRootPath = expectedDefaultWorkspaceRootPath(for: title) - await store.send(.workspaceCreation(.promptRequested)) { - $0.workspaceEditor = WorkspaceEditorFeature.State( - repositories: [], - title: title, - rootPath: requestedRootPath, - openedRepositoryCandidates: [ - ProjectWorkspaceCreationRepository( - id: repoRootA, - name: "Repo A", - rootURL: URL(fileURLWithPath: repoRootA) - ), - ProjectWorkspaceCreationRepository( - id: repoRootB, - name: "API", - rootURL: URL(fileURLWithPath: repoRootB) - ), - ] - ) - } - if resolvedRootPath == requestedRootPath { - await store.receive(\.workspaceCreation.defaultRootPathResolved) - } else { - await store.receive(\.workspaceCreation.defaultRootPathResolved) { - $0.workspaceEditor?.rootPath = resolvedRootPath - } - } - } - - @Test func workspaceEditorLoadsBaseRefsForAddedOpenedRepository() async { - let testID = UUID().uuidString - let repoRootA = "/tmp/\(testID)-repo-a" - let repoRootB = "/tmp/\(testID)-repo-b" - let worktreeA = makeWorktree(id: repoRootA, name: "main", repoRoot: repoRootA) - let repoA = makeRepository(id: repoRootA, name: "Repo A", worktrees: [worktreeA]) - let repoB = makeRepository(id: repoRootB, name: "Repo B", worktrees: []) - var state = makeState(repositories: [repoA, repoB]) - state.repositoryCustomTitles[repoB.id] = "API" - let store = TestStore(initialState: state) { - RepositoriesFeature() - } withDependencies: { - $0.gitClient.repoRoot = { url in url } - $0.gitClient.automaticWorktreeBaseRef = { url in - url.path(percentEncoded: false) == repoRootA ? "main" : "master" - } - $0.gitClient.branchRefOptions = { url in - url.path(percentEncoded: false) == repoRootA - ? [ - GitBranchRefOption(ref: "main", kind: .local), - GitBranchRefOption(ref: "origin/main", kind: .remoteTracking), - ] - : [ - GitBranchRefOption(ref: "master", kind: .local), - GitBranchRefOption(ref: "origin/master", kind: .remoteTracking), - ] - } - } - - let title = "Workspace" - let requestedRootPath = defaultWorkspaceBaseRootPath(for: title) - let resolvedRootPath = expectedDefaultWorkspaceRootPath(for: title) - await store.send(.workspaceCreation(.promptRequested)) { - $0.workspaceEditor = WorkspaceEditorFeature.State( - repositories: [], - title: title, - rootPath: requestedRootPath, - openedRepositoryCandidates: [ - ProjectWorkspaceCreationRepository( - id: repoRootA, - name: "Repo A", - rootURL: URL(fileURLWithPath: repoRootA) - ), - ProjectWorkspaceCreationRepository( - id: repoRootB, - name: "API", - rootURL: URL(fileURLWithPath: repoRootB) - ), - ] - ) - } - if resolvedRootPath == requestedRootPath { - await store.receive(\.workspaceCreation.defaultRootPathResolved) - } else { - await store.receive(\.workspaceCreation.defaultRootPathResolved) { - $0.workspaceEditor?.rootPath = resolvedRootPath - } - } - await store.send(.workspaceEditor(.presented(.addOpenedRepository(repoRootA)))) { - $0.workspaceEditor?.repositories.append( - ProjectWorkspaceCreationRepository( - id: repoRootA, - name: "Repo A", - rootURL: URL(fileURLWithPath: repoRootA) - ) - ) - } - await store.receive(\.workspaceEditor.presented.delegate.baseRefSourceChanged) - await store.receive(\.workspaceCreation.refreshBaseRefs) - await store.receive(\.workspaceCreation.baseRefsLoaded) { - $0.workspaceEditor?.repositories[id: repoRootA]?.baseRef = "main" - $0.workspaceEditor?.repositories[id: repoRootA]?.baseRefOptions = [ - GitBranchRefOption(ref: "main", kind: .local), - GitBranchRefOption(ref: "origin/main", kind: .remoteTracking), - ] - } - } - - @Test func workspaceEditorSubmitBuildsDraft() async { - let repoRootA = "/tmp/repo-a" - let repoRootB = "/tmp/repo-b" - let repositories = [ - ProjectWorkspaceCreationRepository( - id: repoRootA, - name: "Repo A", - rootURL: URL(fileURLWithPath: repoRootA) - ), - ProjectWorkspaceCreationRepository( - id: repoRootB, - name: "Repo B", - rootURL: URL(fileURLWithPath: repoRootB) - ), - ] - let store = TestStore( - initialState: WorkspaceEditorFeature.State( - repositories: repositories, - title: " Multi Repo ", - rootPath: " /tmp/multi-repo-workspace " - ) - ) { - WorkspaceEditorFeature() - } - - await store.send(.submitButtonTapped) - await store.receive( - .delegate( - .submit( - .create( - ProjectWorkspaceCreationDraft( - title: "Multi Repo", - rootURL: URL(filePath: "/tmp/multi-repo-workspace", directoryHint: .isDirectory), - repositories: [ - ProjectWorkspaceRepositoryPlan( - id: repoRootA, - name: "Repo A", - path: nil, - sourceKind: .existingPath, - sourceLocation: repoRootA, - checkout: .link - ), - ProjectWorkspaceRepositoryPlan( - id: repoRootB, - name: "Repo B", - path: nil, - sourceKind: .existingPath, - sourceLocation: repoRootB, - checkout: .link - ), - ] - ) - ) - ) - ) - ) - } - - @Test func workspaceEditorAddsRemoteRepositoryAfterLoadingRefs() async { - let options = [ - GitBranchRefOption(ref: "origin/feature/login", kind: .fetchedRemote), - GitBranchRefOption(ref: "origin/main", kind: .fetchedRemote), - ] - let store = TestStore( - initialState: WorkspaceEditorFeature.State( - repositories: [], - title: "Workspace", - rootPath: "/tmp/workspace" - ) - ) { - WorkspaceEditorFeature() - } withDependencies: { - $0.uuid = .incrementing - $0.gitClient.remoteBranchRefs = { remoteURL in - #expect(remoteURL == "git@github.com:onevcat/app.git") - return GitRemoteBranchRefs(options: options, defaultBaseRef: "origin/main") - } - } - - await store.send(.addRemoteButtonTapped) { - $0.remoteRepositoryPrompt = WorkspaceEditorFeature.RemoteRepositoryPromptState() - } - await store.send(.remoteRepositoryPromptURLChanged(" git@github.com:onevcat/app.git ")) { - $0.remoteRepositoryPrompt?.url = " git@github.com:onevcat/app.git " - $0.remoteRepositoryPrompt?.name = "app" - } - await store.send(.remoteRepositoryPromptLoadButtonTapped) { - $0.remoteRepositoryPrompt?.url = "git@github.com:onevcat/app.git" - $0.remoteRepositoryPrompt?.isLoading = true - } - await store.receive( - .remoteRepositoryPromptLoaded( - "git@github.com:onevcat/app.git", - GitRemoteBranchRefs(options: options, defaultBaseRef: "origin/main")) - ) { - $0.remoteRepositoryPrompt?.isLoading = false - $0.remoteRepositoryPrompt?.branchOptions = options - $0.remoteRepositoryPrompt?.defaultBaseRef = "origin/main" - } - await store.send(.remoteRepositoryPromptAddButtonTapped) { - $0.repositories.append( - ProjectWorkspaceCreationRepository( - id: UUID(0).uuidString, - name: "app", - sourceKind: .remote, - sourceLocation: "git@github.com:onevcat/app.git", - checkoutMode: .useExistingRef, - baseRef: "origin/main", - baseRefOptions: options - ) - ) - $0.remoteRepositoryPrompt = nil - } - } - - @Test func workspaceEditorFolderFollowsTitleUntilEdited() async { - let store = TestStore( - initialState: WorkspaceEditorFeature.State( - repositories: [], - title: "Workspace", - rootPath: "/tmp/workspace" - ) - ) { - WorkspaceEditorFeature() - } - - let requestedRootPath = defaultWorkspaceBaseRootPath(for: "Client App") - let resolvedRootPath = expectedDefaultWorkspaceRootPath(for: "Client App") - await store.send(.titleChanged("Client App")) { - $0.title = "Client App" - $0.rootPath = requestedRootPath - } - if resolvedRootPath == requestedRootPath { - await store.receive( - .automaticRootPathResolved(path: resolvedRootPath, requestedRootPath: requestedRootPath) - ) - } else { - await store.receive( - .automaticRootPathResolved(path: resolvedRootPath, requestedRootPath: requestedRootPath) - ) { - $0.rootPath = resolvedRootPath - } - } - await store.send(.rootPathChanged("/tmp/manual-workspace")) { - $0.rootPath = "/tmp/manual-workspace" - $0.isRootPathDirty = true - } - await store.send(.titleChanged("Other Title")) { - $0.title = "Other Title" - } - } - - @Test func workspaceEditorRemoteLoadUsesDetectedDefaultFromOptions() async { - let store = TestStore( - initialState: WorkspaceEditorFeature.State( - repositories: [], - title: "Workspace", - rootPath: "/tmp/workspace" - ) - ) { - WorkspaceEditorFeature() - } - - await store.send(.addRemoteButtonTapped) { - $0.remoteRepositoryPrompt = WorkspaceEditorFeature.RemoteRepositoryPromptState() - } - await store.send( - .remoteRepositoryPromptLoaded( - "", - GitRemoteBranchRefs( - options: [ - GitBranchRefOption(ref: " origin/main ", kind: .fetchedRemote), - GitBranchRefOption(ref: "origin/main", kind: .fetchedRemote), - ], - defaultBaseRef: "origin/missing" - ) - ) - ) { - $0.remoteRepositoryPrompt?.branchOptions = [ - GitBranchRefOption(ref: "origin/main", kind: .fetchedRemote) - ] - $0.remoteRepositoryPrompt?.defaultBaseRef = "origin/main" - } - } - - @Test func workspaceEditorAddsLocalRepository() async { - let store = TestStore( - initialState: WorkspaceEditorFeature.State( - repositories: [], - title: "Workspace", - rootPath: "/tmp/workspace" - ) - ) { - WorkspaceEditorFeature() - } withDependencies: { - $0.uuid = .incrementing - } - - await store.send(.addRepositoryFromURL(.localRepository, "/tmp/local-api")) { - $0.repositories.append( - ProjectWorkspaceCreationRepository( - id: UUID(0).uuidString, - name: "local-api", - sourceKind: .localRepository, - sourceLocation: "/tmp/local-api" - ) - ) - } - await store.receive(.delegate(.baseRefSourceChanged(UUID(0).uuidString))) - } - - @Test func workspaceEditorClearsAndReloadsBaseRefsWhenSourceLocationChanges() async { - let repositoryID = "repo" - let store = TestStore( - initialState: WorkspaceEditorFeature.State( - repositories: [ - ProjectWorkspaceCreationRepository( - id: repositoryID, - name: "Repo", - sourceKind: .bareRepository, - sourceLocation: "/tmp/repo-a.git", - checkoutMode: .useExistingRef, - baseRef: "origin/main", - baseRefOptions: [GitBranchRefOption(ref: "origin/main", kind: .remoteTracking)] - ) - ], - title: "Workspace", - rootPath: "/tmp/workspace" - ) - ) { - WorkspaceEditorFeature() - } - - await store.send(.repositorySourceLocationChanged(repositoryID, "/tmp/repo-b.git")) { - $0.repositories[id: repositoryID]?.sourceLocation = "/tmp/repo-b.git" - $0.repositories[id: repositoryID]?.baseRef = nil - $0.repositories[id: repositoryID]?.baseRefOptions = [] - } - await store.receive(.delegate(.baseRefSourceChanged(repositoryID))) - } - - @Test func workspaceEditorClearsBaseRefsWhenRemoteSourceLocationChanges() async { - let repositoryID = "remote" - let store = TestStore( - initialState: WorkspaceEditorFeature.State( - repositories: [ - ProjectWorkspaceCreationRepository( - id: repositoryID, - name: "Remote", - sourceKind: .remote, - sourceLocation: "git@github.com:onevcat/app.git", - checkoutMode: .useExistingRef, - baseRef: "origin/main", - baseRefOptions: [GitBranchRefOption(ref: "origin/main", kind: .fetchedRemote)] - ) - ], - title: "Workspace", - rootPath: "/tmp/workspace" - ) - ) { - WorkspaceEditorFeature() - } - - await store.send( - .repositorySourceLocationChanged(repositoryID, "git@github.com:onevcat/other.git") - ) { - $0.repositories[id: repositoryID]?.sourceLocation = "git@github.com:onevcat/other.git" - $0.repositories[id: repositoryID]?.baseRef = nil - $0.repositories[id: repositoryID]?.baseRefOptions = [] - } - } - - @Test func workspaceEditorRejectsUnknownBaseRef() async { - let repoRootA = "/tmp/repo-a" - let repoRootB = "/tmp/repo-b" - let store = TestStore( - initialState: WorkspaceEditorFeature.State( - repositories: [ - ProjectWorkspaceCreationRepository( - id: repoRootA, - name: "Repo A", - rootURL: URL(fileURLWithPath: repoRootA), - baseRefOptions: [GitBranchRefOption(ref: "main", kind: .local)] - ), - ProjectWorkspaceCreationRepository( - id: repoRootB, - name: "Repo B", - rootURL: URL(fileURLWithPath: repoRootB), - baseRefOptions: [GitBranchRefOption(ref: "master", kind: .local)] - ), - ], - title: "Workspace", - rootPath: "/tmp/workspace" - ) - ) { - WorkspaceEditorFeature() - } - - await store.send(.repositoryBaseRefChanged(repoRootA, "missing-branch")) - await store.send(.repositoryBaseRefChanged(repoRootA, "main")) { - $0.repositories[id: repoRootA]?.baseRef = "main" - } - } - - @Test func workspaceEditorRequiresExistingRefForUseExistingMode() async { - let repoRootA = "/tmp/repo-a" - let repoRootB = "/tmp/repo-b" - let store = TestStore( - initialState: WorkspaceEditorFeature.State( - repositories: [ - ProjectWorkspaceCreationRepository( - id: repoRootA, - name: "Repo A", - rootURL: URL(fileURLWithPath: repoRootA), - checkoutMode: .useExistingRef, - baseRefOptions: [GitBranchRefOption(ref: "main", kind: .local)] - ), - ProjectWorkspaceCreationRepository( - id: repoRootB, - name: "Repo B", - rootURL: URL(fileURLWithPath: repoRootB) - ), - ], - title: "Workspace", - rootPath: "/tmp/workspace" - ) - ) { - WorkspaceEditorFeature() - } - - await store.send(.submitButtonTapped) { - $0.validationMessage = "Choose an existing branch for Repo A." - $0.validationTarget = .repository(repoRootA, .baseRef) - $0.validationRequestID = 1 - } - await store.send(.repositoryBaseRefChanged(repoRootA, "main")) { - $0.repositories[id: repoRootA]?.baseRef = "main" - $0.validationMessage = nil - $0.validationTarget = nil - } - } - - @Test func workspaceEditorRequiresBranchNameForCreateBranchMode() async { - let repoRootB = "/tmp/repo-b" - let store = TestStore( - initialState: WorkspaceEditorFeature.State( - repositories: [ - ProjectWorkspaceCreationRepository( - id: "remote", - name: "Remote", - sourceKind: .remote, - sourceLocation: "git@github.com:onevcat/app.git", - checkoutMode: .createBranch, - baseRef: "origin/main", - baseRefOptions: [GitBranchRefOption(ref: "origin/main", kind: .fetchedRemote)] - ), - ProjectWorkspaceCreationRepository( - id: repoRootB, - name: "Repo B", - rootURL: URL(fileURLWithPath: repoRootB) - ), - ], - title: "Workspace", - rootPath: "/tmp/workspace" - ) - ) { - WorkspaceEditorFeature() - } - - await store.send(.submitButtonTapped) { - $0.validationMessage = "Branch name required for Remote." - $0.validationTarget = .repository("remote", .branchName) - $0.validationRequestID = 1 - } - await store.send(.repositoryBranchNameChanged("remote", "codex/app")) { - $0.repositories[id: "remote"]?.branchName = "codex/app" - $0.validationMessage = nil - $0.validationTarget = nil - } - } - - @Test func workspaceEditorResetsLinkModeWhenSwitchingToBare() async { - let repositoryID = "repo" - let store = TestStore( - initialState: WorkspaceEditorFeature.State( - repositories: [ - ProjectWorkspaceCreationRepository( - id: repositoryID, - name: "Repo", - rootURL: URL(fileURLWithPath: "/tmp/repo") - ) - ], - title: "Workspace", - rootPath: "/tmp/workspace" - ) - ) { - WorkspaceEditorFeature() - } - - await store.send(.repositorySourceKindChanged(repositoryID, .bareRepository)) { - $0.repositories[id: repositoryID]?.sourceKind = .bareRepository - $0.repositories[id: repositoryID]?.checkoutMode = .createBranch - } - await store.receive(.delegate(.baseRefSourceChanged(repositoryID))) - } - @Test func requestRemoveWorkspaceShowsConfirmationSheet() async { let workspaceID = "/tmp/ws" let repository = makeRepository( @@ -1872,40 +1265,6 @@ struct RepositoriesFeatureTests { #expect(ProjectWorkspace.load(from: rootURL)?.title == "Feature") } - @Test func workspaceBaseRefsLoadErrorSurfacesValidationMessage() async { - let repositoryID = "/tmp/repo" - var initialState = RepositoriesFeature.State() - initialState.workspaceEditor = WorkspaceEditorFeature.State( - repositories: [ - ProjectWorkspaceCreationRepository( - id: repositoryID, - name: "Repo", - rootURL: URL(fileURLWithPath: repositoryID) - ) - ], - title: "Workspace", - rootPath: "/tmp/workspace" - ) - let store = TestStore(initialState: initialState) { - RepositoriesFeature() - } - - await store.send( - .workspaceCreation( - .baseRefsLoaded( - repositoryID: repositoryID, - sourceKind: .existingPath, - sourceLocation: repositoryID, - options: [], - defaultBaseRef: nil, - errorMessage: "Could not read branches for Repo: boom" - ) - ) - ) { - $0.workspaceEditor?.validationMessage = "Could not read branches for Repo: boom" - } - } - @Test func loadPersistedRepositoriesAutoUpgradesPlainFolderWhenItBecomesGitRoot() async { let root = "/tmp/folder" let worktree = makeWorktree(id: root, name: "folder", repoRoot: root) diff --git a/supacodeTests/WorkspaceEditorFeatureTests.swift b/supacodeTests/WorkspaceEditorFeatureTests.swift index 0c5a8c01..390ea121 100644 --- a/supacodeTests/WorkspaceEditorFeatureTests.swift +++ b/supacodeTests/WorkspaceEditorFeatureTests.swift @@ -37,23 +37,25 @@ struct WorkspaceEditorFeatureTests { id: rootURL.path(percentEncoded: false), title: "Checkout Flow", description: "Ship the flow", - taskLinks: ["https://example.com/issues/1"], + taskLinks: ["https://example.com/issues/1", "PROWL-7"], repositories: [appEntry, apiEntry] ) } + private var sharedCandidate: ProjectWorkspaceCreationRepository { + ProjectWorkspaceCreationRepository( + id: "/tmp/source/shared", + name: "Shared", + rootURL: URL(fileURLWithPath: "/tmp/source/shared") + ) + } + private func makeEditState() -> WorkspaceEditorFeature.State { WorkspaceEditorFeature.State( editing: workspace, rootURL: rootURL, repositoryID: "/tmp/workspaces/checkout-flow", - openedRepositoryCandidates: [ - ProjectWorkspaceCreationRepository( - id: "/tmp/source/shared", - name: "Shared", - rootURL: URL(fileURLWithPath: "/tmp/source/shared") - ) - ] + openedRepositoryCandidates: [sharedCandidate] ) } @@ -63,17 +65,17 @@ struct WorkspaceEditorFeatureTests { #expect(state.mode == .edit(repositoryID: "/tmp/workspaces/checkout-flow")) #expect(state.title == "Checkout Flow") #expect(state.description == "Ship the flow") - #expect(state.taskLinks.map(\.value) == ["https://example.com/issues/1"]) - #expect(state.taskLinks.map(\.id) == ["task-link-0"]) + #expect(state.taskLinksText == "https://example.com/issues/1\nPROWL-7") + #expect(state.taskLinks == ["https://example.com/issues/1", "PROWL-7"]) #expect(state.rootPath == "/tmp/workspaces/checkout-flow") #expect(state.isRootPathDirty) #expect(state.existingRepositories.map(\.id) == ["app", "api"]) #expect(state.existingRepositories[id: "app"]?.role == "macOS app") - #expect(state.existingRepositories[id: "api"]?.role == "") #expect(state.existingRepositories[id: "app"]?.offersBranchDeletion == true) #expect(state.existingRepositories[id: "api"]?.offersBranchDeletion == false) #expect(state.repositories.isEmpty) #expect(state.remainingRepositoryCount == 2) + #expect(state.memberEditor == nil) } @Test func titleChangeInEditModeKeepsRootPath() async { @@ -87,150 +89,128 @@ struct WorkspaceEditorFeatureTests { #expect(store.state.rootPath == "/tmp/workspaces/checkout-flow") } - @Test func taskLinksCanBeAddedEditedAndRemoved() async { + @Test func taskLinksTextParsesOneLinkPerLineAndDropsBlanks() async { let store = TestStore(initialState: makeEditState()) { WorkspaceEditorFeature() - } withDependencies: { - $0.uuid = .incrementing } - await store.send(.addTaskLinkButtonTapped) { - $0.taskLinks.append(WorkspaceTaskLinkDraft(id: UUID(0).uuidString, value: "")) + await store.send(.taskLinksTextChanged(" https://example.com/issues/2 \n\n PROWL-42\n ")) { + $0.taskLinksText = " https://example.com/issues/2 \n\n PROWL-42\n " } - await store.send(.taskLinkChanged(UUID(0).uuidString, "PROWL-42")) { - $0.taskLinks[id: UUID(0).uuidString]?.value = "PROWL-42" + #expect(store.state.taskLinks == ["https://example.com/issues/2", "PROWL-42"]) + } + + @Test func addRepositoryPresentsTheMemberEditorWithUnusedCandidates() async { + var state = makeEditState() + state.repositories = [sharedCandidate] + let store = TestStore(initialState: state) { + WorkspaceEditorFeature() } - await store.send(.removeTaskLink("task-link-0")) { - $0.taskLinks.remove(id: "task-link-0") + + // The only candidate is already added, so the picker offers nothing. + await store.send(.addRepositoryButtonTapped) { + $0.memberEditor = .add(openedCandidates: []) } - #expect(store.state.taskLinks.map(\.value) == ["PROWL-42"]) } - @Test func removalMarkingGatesDeleteFlagsAndCanBeUndone() async { + @Test func memberEditorCommitAddsANewRowAndReplacesAReEditedOne() async { let store = TestStore(initialState: makeEditState()) { WorkspaceEditorFeature() } - // Flags on an unmarked member are ignored. - await store.send(.existingRepositoryDeleteFilesChanged("app", true)) - await store.send(.existingRepositoryMarkedForRemoval("app")) { - $0.existingRepositories[id: "app"]?.removal = .init() - } - #expect(store.state.remainingRepositoryCount == 1) - #expect(store.state.hasPendingRemovals) - // Branch deletion requires file deletion. - await store.send(.existingRepositoryDeleteBranchChanged("app", true)) - await store.send(.existingRepositoryDeleteFilesChanged("app", true)) { - $0.existingRepositories[id: "app"]?.removal?.deleteFiles = true + await store.send(.addRepositoryButtonTapped) { + $0.memberEditor = .add(openedCandidates: [self.sharedCandidate]) } - await store.send(.existingRepositoryDeleteBranchChanged("app", true)) { - $0.existingRepositories[id: "app"]?.removal?.deleteBranch = true + await store.send(.memberEditor(.presented(.delegate(.commitAdded(sharedCandidate))))) { + $0.repositories = [self.sharedCandidate] + $0.memberEditor = nil } - // Turning file deletion off clears the branch choice. - await store.send(.existingRepositoryDeleteFilesChanged("app", false)) { - $0.existingRepositories[id: "app"]?.removal = .init(deleteFiles: false, deleteBranch: false) + #expect(store.state.orderedMemberKeys == [.existing("app"), .existing("api"), .added("/tmp/source/shared")]) + + var renamed = sharedCandidate + renamed.name = "Shared Kit" + renamed.role = "library" + await store.send(.editMember(.added("/tmp/source/shared"))) { + $0.memberEditor = .editAdded(self.sharedCandidate) } - await store.send(.existingRepositoryRemovalUndone("app")) { - $0.existingRepositories[id: "app"]?.removal = nil + await store.send(.memberEditor(.presented(.delegate(.commitAdded(renamed))))) { + $0.repositories = [renamed] + $0.memberEditor = nil } - // A clone has no branch to delete. - await store.send(.existingRepositoryMarkedForRemoval("api")) { - $0.existingRepositories[id: "api"]?.removal = .init() + await store.send(.editMember(.added("/tmp/source/shared"))) { + $0.memberEditor = .editAdded(renamed) } - await store.send(.existingRepositoryDeleteFilesChanged("api", true)) { - $0.existingRepositories[id: "api"]?.removal?.deleteFiles = true + await store.send(.memberEditor(.presented(.delegate(.removeAdded("/tmp/source/shared"))))) { + $0.repositories = [] + $0.memberEditor = nil } - await store.send(.existingRepositoryDeleteBranchChanged("api", true)) } - @Test func existingMembersCanBeReordered() async { + @Test func editingAnExistingMemberRoundTripsNameRoleAndRemoval() async { let store = TestStore(initialState: makeEditState()) { WorkspaceEditorFeature() } + var app = WorkspaceEditorExistingRepository(entry: appEntry) + app.name = "Mac App" + app.role = "" + app.removal = .init(deleteFiles: true, deleteBranch: true) - #expect(store.state.orderedMemberKeys == [.existing("app"), .existing("api")]) - await store.send(.memberMovedUp(.existing("app"))) - await store.send(.memberMovedDown(.existing("app"))) { - $0.memberOrder = [.existing("api"), .existing("app")] + await store.send(.editMember(.existing("app"))) { + $0.memberEditor = .editExisting(WorkspaceEditorExistingRepository(entry: self.appEntry)) + } + await store.send(.memberEditor(.presented(.delegate(.commitExisting(app))))) { + $0.existingRepositories[id: "app"] = app + $0.memberEditor = nil + } + #expect(store.state.remainingRepositoryCount == 1) + await store.send(.undoRemoval("app")) { + $0.existingRepositories[id: "app"]?.removal = nil + } + var reopened = app + reopened.removal = nil + await store.send(.editMember(.existing("app"))) { + $0.memberEditor = .editExisting(reopened) } - await store.send(.memberMovedDown(.existing("app"))) - await store.send(.memberMovedUp(.existing("app"))) { - $0.memberOrder = [.existing("app"), .existing("api")] + await store.send(.memberEditor(.presented(.delegate(.cancel)))) { + $0.memberEditor = nil } - #expect(store.state.orderedMembers.map(\.id) == [.existing("app"), .existing("api")]) } - @Test func addedMemberCanMoveBeforeExistingMembersAndSubmitKeepsThatOrder() async { - let now = Date(timeIntervalSince1970: 1_700_000_000) - let shared = ProjectWorkspaceCreationRepository( - id: "/tmp/source/shared", - name: "Shared", - rootURL: URL(fileURLWithPath: "/tmp/source/shared") - ) - let store = TestStore(initialState: makeEditState()) { + @Test func removeMemberMarksExistingAndDropsAdded() async { + var state = makeEditState() + state.repositories = [sharedCandidate] + let store = TestStore(initialState: state) { WorkspaceEditorFeature() - } withDependencies: { - $0.date.now = now } - await store.send(.addOpenedRepository("/tmp/source/shared")) { - $0.repositories = [shared] - } - await store.receive(\.delegate.baseRefSourceChanged) - // Added rows append after existing members until they are moved. - #expect( - store.state.orderedMemberKeys == [.existing("app"), .existing("api"), .added("/tmp/source/shared")]) - await store.send(.memberMovedUp(.added("/tmp/source/shared"))) { - $0.memberOrder = [.existing("app"), .added("/tmp/source/shared"), .existing("api")] + await store.send(.removeMember(.existing("api"))) { + $0.existingRepositories[id: "api"]?.removal = .init() } - await store.send(.memberMovedUp(.added("/tmp/source/shared"))) { - $0.memberOrder = [.added("/tmp/source/shared"), .existing("app"), .existing("api")] + await store.send(.removeMember(.added("/tmp/source/shared"))) { + $0.repositories = [] } - await store.send(.memberMovedUp(.added("/tmp/source/shared"))) - - await store.send(.submitButtonTapped) - await store.receive( - .delegate( - .submit( - .update( - ProjectWorkspaceUpdateRequest( - rootURL: rootURL, - title: "Checkout Flow", - description: "Ship the flow", - taskLinks: ["https://example.com/issues/1"], - members: [ - .added( - ProjectWorkspaceRepositoryPlan( - id: "/tmp/source/shared", - name: "Shared", - path: nil, - sourceKind: .existingPath, - sourceLocation: "/tmp/source/shared", - checkout: .link - )), - .existing(appEntry), - .existing(apiEntry), - ], - updatedAt: now - ) - ) - ) - ) - ) + await store.send(.removeMember(.existing("missing"))) + #expect(store.state.hasPendingRemovals) + #expect(store.state.remainingRepositoryCount == 1) } - @Test func removedRowsDropOutOfTheOrder() async { + @Test func membersCanBeReorderedAcrossExistingAndAddedRows() async { var state = makeEditState() - state.repositories = [ - ProjectWorkspaceCreationRepository( - id: "/tmp/source/shared", name: "Shared", rootURL: URL(fileURLWithPath: "/tmp/source/shared")) - ] - state.memberOrder = [.added("/tmp/source/shared"), .existing("app"), .existing("api")] + state.repositories = [sharedCandidate] let store = TestStore(initialState: state) { WorkspaceEditorFeature() } - await store.send(.removeRepository("/tmp/source/shared")) { + #expect(store.state.orderedMemberKeys == [.existing("app"), .existing("api"), .added("/tmp/source/shared")]) + await store.send(.memberMovedUp(.existing("app"))) + await store.send(.memberMovedUp(.added("/tmp/source/shared"))) { + $0.memberOrder = [.existing("app"), .added("/tmp/source/shared"), .existing("api")] + } + await store.send(.memberMovedUp(.added("/tmp/source/shared"))) { + $0.memberOrder = [.added("/tmp/source/shared"), .existing("app"), .existing("api")] + } + await store.send(.memberMovedDown(.existing("api"))) + await store.send(.removeMember(.added("/tmp/source/shared"))) { $0.repositories = [] } #expect(store.state.orderedMemberKeys == [.existing("app"), .existing("api")]) @@ -246,34 +226,23 @@ struct WorkspaceEditorFeatureTests { await store.send(.submitButtonTapped) { $0.validationMessage = "Add at least one repository." - $0.validationTarget = nil - $0.validationRequestID = 1 + $0.validationTarget = .repositories } } - @Test func submitInEditModeBuildsUpdateRequest() async { + @Test func submitInEditModeBuildsUpdateRequestInRowOrder() async { let now = Date(timeIntervalSince1970: 1_700_000_000) var state = makeEditState() state.title = " Checkout Flow v2 " state.description = " Ship it \n" - state.taskLinks = [ - WorkspaceTaskLinkDraft(id: "a", value: " https://example.com/issues/2 "), - WorkspaceTaskLinkDraft(id: "b", value: " "), - ] + state.taskLinksText = " https://example.com/issues/2 \n\n" state.existingRepositories[id: "app"]?.name = " Mac App " state.existingRepositories[id: "app"]?.role = "" state.existingRepositories[id: "api"]?.removal = .init(deleteFiles: true, deleteBranch: false) - state.repositories = [ - { - var repository = ProjectWorkspaceCreationRepository( - id: "/tmp/source/shared", - name: "Shared", - rootURL: URL(fileURLWithPath: "/tmp/source/shared") - ) - repository.role = " library " - return repository - }() - ] + var shared = sharedCandidate + shared.role = " library " + state.repositories = [shared] + state.memberOrder = [.added("/tmp/source/shared"), .existing("app"), .existing("api")] let store = TestStore(initialState: state) { WorkspaceEditorFeature() } withDependencies: { @@ -294,7 +263,6 @@ struct WorkspaceEditorFeatureTests { description: "Ship it", taskLinks: ["https://example.com/issues/2"], members: [ - .existing(renamedApp), .added( ProjectWorkspaceRepositoryPlan( id: "/tmp/source/shared", @@ -305,6 +273,7 @@ struct WorkspaceEditorFeatureTests { sourceLocation: "/tmp/source/shared", checkout: .link )), + .existing(renamedApp), ], removals: [ ProjectWorkspaceRepositoryRemoval(entry: apiEntry, deleteFiles: true, deleteBranch: false) @@ -317,20 +286,20 @@ struct WorkspaceEditorFeatureTests { ) } - @Test func submitInCreateModeCarriesDescriptionLinksAndRole() async { - var repository = ProjectWorkspaceCreationRepository( - id: "/tmp/repo-a", - name: "Repo A", - rootURL: URL(fileURLWithPath: "/tmp/repo-a") - ) - repository.role = "app" + @Test func submitInCreateModeCarriesDescriptionLinksRoleAndOrder() async { + var repoA = ProjectWorkspaceCreationRepository( + id: "/tmp/repo-a", name: "Repo A", rootURL: URL(fileURLWithPath: "/tmp/repo-a")) + repoA.role = "app" + let repoB = ProjectWorkspaceCreationRepository( + id: "/tmp/repo-b", name: "Repo B", rootURL: URL(fileURLWithPath: "/tmp/repo-b")) var state = WorkspaceEditorFeature.State( - repositories: [repository], - title: "Solo", - rootPath: "/tmp/solo-workspace" + repositories: [repoA, repoB], + title: " Multi Repo ", + rootPath: " /tmp/multi-repo-workspace " ) - state.description = "Just one" - state.taskLinks = [WorkspaceTaskLinkDraft(id: "a", value: "PROWL-1")] + state.description = "Just two" + state.taskLinksText = "PROWL-1" + state.memberOrder = [.added("/tmp/repo-b"), .added("/tmp/repo-a")] let store = TestStore(initialState: state) { WorkspaceEditorFeature() } @@ -341,11 +310,19 @@ struct WorkspaceEditorFeatureTests { .submit( .create( ProjectWorkspaceCreationDraft( - title: "Solo", - description: "Just one", + title: "Multi Repo", + description: "Just two", taskLinks: ["PROWL-1"], - rootURL: URL(filePath: "/tmp/solo-workspace", directoryHint: .isDirectory), + rootURL: URL(filePath: "/tmp/multi-repo-workspace", directoryHint: .isDirectory), repositories: [ + ProjectWorkspaceRepositoryPlan( + id: "/tmp/repo-b", + name: "Repo B", + path: nil, + sourceKind: .existingPath, + sourceLocation: "/tmp/repo-b", + checkout: .link + ), ProjectWorkspaceRepositoryPlan( id: "/tmp/repo-a", name: "Repo A", @@ -354,7 +331,7 @@ struct WorkspaceEditorFeatureTests { sourceKind: .existingPath, sourceLocation: "/tmp/repo-a", checkout: .link - ) + ), ] ) ) @@ -363,7 +340,7 @@ struct WorkspaceEditorFeatureTests { ) } - @Test func submitValidatesAddedRowsBeforeBuildingUpdate() async { + @Test func submitReportsAStaleInvalidRow() async { var state = makeEditState() state.repositories = [ ProjectWorkspaceCreationRepository( @@ -380,8 +357,30 @@ struct WorkspaceEditorFeatureTests { await store.send(.submitButtonTapped) { $0.validationMessage = "Choose an existing branch for Remote." - $0.validationTarget = .repository("remote", .baseRef) - $0.validationRequestID = 1 + $0.validationTarget = .repositories + } + } + + @Test func createModeFolderFollowsTitleUntilChosen() async { + let store = TestStore( + initialState: WorkspaceEditorFeature.State(repositories: [], title: "Workspace", rootPath: "/tmp/x") + ) { + WorkspaceEditorFeature() + } + store.exhaustivity = .off + + await store.send(.titleChanged("Checkout Flow")) { + $0.title = "Checkout Flow" + $0.rootPath = ProjectWorkspace.workspaceRootPath(folderName: "Checkout-Flow", suffix: nil) + } + await store.receive(\.automaticRootPathResolved) + await store.send(.rootPathChosen("/tmp/custom")) { + $0.rootPath = "/tmp/custom" + $0.isRootPathDirty = true + } + await store.send(.titleChanged("Other")) { + $0.title = "Other" } + #expect(store.state.rootPath == "/tmp/custom") } } diff --git a/supacodeTests/WorkspaceMemberEditorFeatureTests.swift b/supacodeTests/WorkspaceMemberEditorFeatureTests.swift new file mode 100644 index 00000000..35b7bd2a --- /dev/null +++ b/supacodeTests/WorkspaceMemberEditorFeatureTests.swift @@ -0,0 +1,271 @@ +import ComposableArchitecture +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct WorkspaceMemberEditorFeatureTests { + private let candidate = ProjectWorkspaceCreationRepository( + id: "/tmp/source/app", + name: "App", + rootURL: URL(fileURLWithPath: "/tmp/source/app") + ) + + private let mainAndFeature = [ + GitBranchRefOption(ref: "main", kind: .local), + GitBranchRefOption(ref: "feature/login", kind: .local), + GitBranchRefOption(ref: "origin/main", kind: .remoteTracking), + ] + + @Test func choosingAnOpenedCandidateLoadsItsBaseRefs() async { + let store = TestStore(initialState: .add(openedCandidates: [candidate])) { + WorkspaceMemberEditorFeature() + } withDependencies: { + $0.gitClient.repoRoot = { url in url } + $0.gitClient.automaticWorktreeBaseRef = { _ in "main" } + $0.gitClient.branchRefOptions = { _ in self.mainAndFeature } + } + + await store.send(.sourceChosen(.opened)) { + $0.sourceChoice = .opened + } + await store.send(.openedCandidateChosen("/tmp/source/app")) { + $0.draft = self.candidate + $0.isLoadingBaseRefs = true + } + await store.receive(\.baseRefsLoaded) { + $0.isLoadingBaseRefs = false + $0.draft?.baseRefOptions = self.mainAndFeature + $0.draft?.baseRef = "main" + } + // Link needs no ref, so Add succeeds as-is. + await store.send(.commitButtonTapped) + await store.receive(\.delegate.commitAdded) + } + + @Test func baseRefLoadFailureIsShownButKeepsTheDraft() async { + let store = TestStore(initialState: .add(openedCandidates: [candidate])) { + WorkspaceMemberEditorFeature() + } withDependencies: { + $0.gitClient.repoRoot = { url in url } + $0.gitClient.automaticWorktreeBaseRef = { _ in nil } + $0.gitClient.branchRefOptions = { _ in throw GitClientError.notRepository } + } + store.exhaustivity = .off + + await store.send(.openedCandidateChosen("/tmp/source/app")) + await store.receive(\.baseRefsLoaded) { + $0.isLoadingBaseRefs = false + $0.validationMessage = "Could not read branches for App: \(GitClientError.notRepository.localizedDescription)" + } + #expect(store.state.draft?.baseRefOptions == []) + } + + @Test func localFolderChoiceCreatesADraftNamedAfterTheFolder() async { + let store = TestStore(initialState: .add(openedCandidates: [])) { + WorkspaceMemberEditorFeature() + } withDependencies: { + $0.uuid = .incrementing + $0.gitClient.repoRoot = { url in url } + $0.gitClient.automaticWorktreeBaseRef = { _ in "main" } + $0.gitClient.branchRefOptions = { _ in self.mainAndFeature } + } + store.exhaustivity = .off + + await store.send(.localFolderChosen("/tmp/source/api.git/")) + #expect(store.state.draft?.id == UUID(0).uuidString) + #expect(store.state.draft?.name == "api") + #expect(store.state.draft?.sourceKind == .localRepository) + #expect(store.state.draft?.checkoutMode == .link) + await store.receive(\.baseRefsLoaded) + #expect(store.state.draft?.baseRef == "main") + } + + @Test func remoteURLLoadsBranchesAfterTypingPausesAndBuildsAClone() async { + let clock = TestClock() + let store = TestStore(initialState: .add(openedCandidates: [])) { + WorkspaceMemberEditorFeature() + } withDependencies: { + $0.uuid = .incrementing + $0.continuousClock = clock + $0.gitClient.remoteBranchRefs = { _ in + GitRemoteBranchRefs( + options: [ + GitBranchRefOption(ref: "origin/develop", kind: .fetchedRemote), + GitBranchRefOption(ref: "origin/main", kind: .fetchedRemote), + ], + defaultBaseRef: "origin/main" + ) + } + } + + await store.send(.sourceChosen(.remote)) { + $0.sourceChoice = .remote + } + await store.send(.remoteURLChanged("git@github.com:onevcat/api.gi")) { + $0.remoteURL = "git@github.com:onevcat/api.gi" + } + // More typing restarts the pause. + await store.send(.remoteURLChanged("git@github.com:onevcat/api.git")) { + $0.remoteURL = "git@github.com:onevcat/api.git" + } + await clock.advance(by: .milliseconds(800)) + await store.receive(\.remoteURLCommitted) { + $0.isLoadingRemote = true + } + await store.receive(\.remoteRefsLoaded) { + $0.isLoadingRemote = false + $0.draft = ProjectWorkspaceCreationRepository( + id: UUID(0).uuidString, + name: "api", + sourceKind: .remote, + sourceLocation: "git@github.com:onevcat/api.git", + checkoutMode: .useExistingRef, + baseRef: "origin/main", + baseRefOptions: [ + GitBranchRefOption(ref: "origin/develop", kind: .fetchedRemote), + GitBranchRefOption(ref: "origin/main", kind: .fetchedRemote), + ] + ) + } + await store.send(.commitButtonTapped) + await store.receive(\.delegate.commitAdded) + } + + @Test func remoteFailureIsReportedAndCommitStaysUnavailable() async { + let clock = TestClock() + let store = TestStore(initialState: .add(openedCandidates: [])) { + WorkspaceMemberEditorFeature() + } withDependencies: { + $0.continuousClock = clock + $0.gitClient.remoteBranchRefs = { _ in throw GitClientError.notRepository } + } + + await store.send(.remoteURLChanged("git@github.com:onevcat/none.git")) { + $0.remoteURL = "git@github.com:onevcat/none.git" + } + await store.send(.remoteURLCommitted) { + $0.isLoadingRemote = true + } + await store.receive(\.remoteRefsFailed) { + $0.isLoadingRemote = false + $0.remoteErrorMessage = GitClientError.notRepository.localizedDescription + } + #expect(!store.state.isConfiguring) + await store.send(.commitButtonTapped) + } + + @Test func commitValidatesTheDraftAndPointsAtTheField() async { + var draft = candidate + draft.baseRefOptions = mainAndFeature + draft.baseRef = "main" + let store = TestStore(initialState: .editAdded(draft)) { + WorkspaceMemberEditorFeature() + } + + await store.send(.checkoutModeChanged(.createBranch)) { + $0.draft?.checkoutMode = .createBranch + } + await store.send(.commitButtonTapped) { + $0.validationMessage = "Branch name required for App." + $0.validationField = .branchName + } + await store.send(.branchNameChanged("feature/checkout")) { + $0.draft?.branchName = "feature/checkout" + $0.validationMessage = nil + $0.validationField = nil + } + await store.send(.baseRefChanged("nope")) + await store.send(.baseRefChanged("feature/login")) { + $0.draft?.baseRef = "feature/login" + } + await store.send(.roleChanged("app")) { + $0.draft?.role = "app" + } + await store.send(.commitButtonTapped) + var expected = draft + expected.checkoutMode = .createBranch + expected.branchName = "feature/checkout" + expected.baseRef = "feature/login" + expected.role = "app" + await store.receive(.delegate(.commitAdded(expected))) + } + + @Test func resetLocalBranchChoiceTogglesAndClearsOnRefChange() async { + let draft = ProjectWorkspaceCreationRepository( + id: "bare", + name: "Repo A", + sourceKind: .bareRepository, + sourceLocation: "/tmp/repo-a.git", + checkoutMode: .useExistingRef, + baseRef: "origin/feature", + baseRefOptions: [ + GitBranchRefOption(ref: "origin/feature", kind: .remoteTracking), + GitBranchRefOption(ref: "feature", kind: .local), + ] + ) + let store = TestStore(initialState: .editAdded(draft)) { + WorkspaceMemberEditorFeature() + } + + #expect(store.state.draft?.resettableLocalBranchName == "feature") + await store.send(.resetLocalBranchChanged(true)) { + $0.draft?.resetLocalBranchToRemote = true + } + // Selecting a different ref invalidates the keep/reset choice. + await store.send(.baseRefChanged("feature")) { + $0.draft?.baseRef = "feature" + $0.draft?.resetLocalBranchToRemote = false + } + } + + @Test func remoteDraftCannotSwitchToLink() async { + let draft = ProjectWorkspaceCreationRepository( + id: "r", name: "api", sourceKind: .remote, sourceLocation: "git@github.com:onevcat/api.git", + checkoutMode: .useExistingRef, baseRef: "origin/main", + baseRefOptions: [GitBranchRefOption(ref: "origin/main", kind: .fetchedRemote)]) + let store = TestStore(initialState: .editAdded(draft)) { + WorkspaceMemberEditorFeature() + } + + await store.send(.checkoutModeChanged(.link)) + await store.send(.removeAddedButtonTapped) + await store.receive(.delegate(.removeAdded("r"))) + } + + @Test func existingMemberEditGatesRemovalFlagsAndCommitsAsIs() async { + let entry = ProjectWorkspaceRepositoryEntry( + id: "app", name: "App", path: "app", sourceKind: .existingPath, + sourceLocation: "/tmp/source/app", branchName: "feature/app") + let store = TestStore(initialState: .editExisting(WorkspaceEditorExistingRepository(entry: entry))) { + WorkspaceMemberEditorFeature() + } + + await store.send(.existingNameChanged("Mac App")) { + $0.existing?.name = "Mac App" + } + await store.send(.existingRoleChanged("app")) { + $0.existing?.role = "app" + } + // Flags are ignored until the member is marked for removal. + await store.send(.existingDeleteFilesChanged(true)) + await store.send(.existingRemovalChanged(true)) { + $0.existing?.removal = .init() + } + await store.send(.existingDeleteBranchChanged(true)) + await store.send(.existingDeleteFilesChanged(true)) { + $0.existing?.removal?.deleteFiles = true + } + await store.send(.existingDeleteBranchChanged(true)) { + $0.existing?.removal?.deleteBranch = true + } + await store.send(.existingDeleteFilesChanged(false)) { + $0.existing?.removal = .init(deleteFiles: false, deleteBranch: false) + } + await store.send(.commitButtonTapped) + await store.receive(\.delegate.commitExisting) + await store.send(.backButtonTapped) + await store.receive(\.delegate.cancel) + } +} -- 2.51.2 From 5d8b2ea84c427b457a6550e9359b08362ff56a2b Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 25 Sep 2026 23:12:29 +0800 Subject: [PATCH 15/15] Explain the disabled Save when no repository would remain; record the two-level editor in docs-ai 072 --- docs-ai/072-workspace-editing/001-action.md | 26 ++++++++++++++----- .../Views/WorkspaceEditorView.swift | 3 +++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/docs-ai/072-workspace-editing/001-action.md b/docs-ai/072-workspace-editing/001-action.md index b61159ed..05ccb3ea 100644 --- a/docs-ai/072-workspace-editing/001-action.md +++ b/docs-ai/072-workspace-editing/001-action.md @@ -9,6 +9,7 @@ | 2026-09-25 | `WorkspaceCreationPromptFeature` → `WorkspaceEditorFeature` with `mode`, existing-member rows, staged removal, reorder, task links, description; `WorkspaceCreationPromptView` → `WorkspaceEditorView` | PR #830 | | 2026-09-25 | `RepositoriesFeature+WorkspaceEditing.swift` (`workspaceEditing` action family), entry points in sidebar, detail view, Settings, Command Palette, Worktrees menu | PR #830 | | 2026-09-25 | Tests, `docs/` manual updates, this record | PR #830 | +| 2026-09-25 | Two-level editor (002): `WorkspaceEditorFeature` keeps metadata + member list, new `WorkspaceMemberEditorFeature` / `WorkspaceMemberEditorView` add or edit one repository at a time; base-ref loading moved out of `RepositoriesFeature`; Debug pass repeated on the new sheets (screenshots on PR #830) | PR #830 | | 2026-09-25 | Debug visual verification in an isolated instance: create sheet, sidebar after create, palette item, edit sheet with staged removal, interleaved reorder, save (toast, disk, branch cleanup), Settings entry surfacing the main window; screenshots on PR #830 | PR #830 | | 2026-09-25 | Review Loop round 4 (Pi Reviewer): New Workspace… no longer replaces an open editor sheet (unsaved edits or an in-flight save survive) | PR #830 | | 2026-09-25 | Review Loop round 3 (Pi Reviewer): the creation sheet's default-folder resolver is cancelled on dismissal and its result only applies to a `.create` editor, so it can never rewrite an edit session's root | PR #830 | @@ -39,9 +40,19 @@ - **Editor reducer** — `supacode/Features/Repositories/Reducer/WorkspaceEditorFeature.swift` - `State.mode` (`.create` / `.edit(repositoryID:)`), `existingRepositories` (`WorkspaceEditorExistingRepository`: entry, editable name/role, `removal` with - `deleteFiles` / `deleteBranch`), `repositories` (new rows, unchanged), `description`, - `taskLinks` (`WorkspaceTaskLinkDraft`), `isSaving`. `init(editing:rootURL:repositoryID:...)` - pre-fills from a `ProjectWorkspace`. + `deleteFiles` / `deleteBranch`), `repositories` (added drafts), `description`, + `taskLinksText` (one link per line, parsed by `taskLinks`), `isSaving`, and + `@Presents memberEditor`. `init(editing:rootURL:repositoryID:...)` pre-fills from a + `ProjectWorkspace`. `addRepositoryButtonTapped` / `editMember` present the member + editor; its `commitAdded` / `commitExisting` / `removeAdded` delegates update the lists. +- **Member editor** — `supacode/Features/Repositories/Reducer/WorkspaceMemberEditorFeature.swift` + (`Mode.add` / `.editAdded` / `.editExisting`; source step with opened candidates, local + folder, and a remote URL that loads branches after an 800 ms pause or Return; configure + step on a `ProjectWorkspaceCreationRepository` draft with base refs loaded through + `GitClientDependency`; existing members expose name, role, and the staged removal) and + `supacode/Features/Repositories/Views/WorkspaceMemberEditorView.swift` (source cards, + segmented Link / New Branch / Existing Branch, Advanced folder field, shared + `WorkspaceMemberSummary` sentences, `WorkspaceBranchRefPickerView`). - `memberOrder` / `orderedMemberKeys` hold one order across existing and added rows (`WorkspaceEditorMemberKey`); `memberMovedUp` / `memberMovedDown` work on any row and the submission follows that order. @@ -61,10 +72,11 @@ is already gone). Save is deliberately not cancellable. - `RepositoriesFeature.State.workspaceEditor` replaces `workspaceCreationPrompt`; `RepositoriesFeature+CoreReducer.swift` routes the editor delegate by mode. -- **Views** — `supacode/Features/Repositories/Views/WorkspaceEditorView.swift` (shared form; - existing rows with Name/Role, provenance line, Move Up/Down, trash → inline removal - options + Undo; new rows gain Role; Description and Task Links sections; Cancel disabled - while saving in edit mode), `WorkspaceDetailView.swift` (Edit Workspace… button, +- **Views** — `supacode/Features/Repositories/Views/WorkspaceEditorView.swift` (sheet + container that swaps between the workspace panel and the member editor; the panel has + Title, Description, Task Links, derived Folder + Change…, the member rows with a + what-will-happen sentence, hover Edit / Remove, Undo, context-menu Move Up / Down, and a + footer summary; Cancel disabled while saving in edit mode), `WorkspaceDetailView.swift` (Edit Workspace… button, clickable http(s) task links), `WorkspaceChildRowsView.swift` (Edit Workspace… / Remove from Workspace…), `RepositorySectionView.swift` (header menu item), `supacode/Features/Settings/Views/RepositorySettingsView.swift` (Edit Workspace… button diff --git a/supacode/Features/Repositories/Views/WorkspaceEditorView.swift b/supacode/Features/Repositories/Views/WorkspaceEditorView.swift index 6d9c9cfe..f476c737 100644 --- a/supacode/Features/Repositories/Views/WorkspaceEditorView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceEditorView.swift @@ -287,6 +287,9 @@ private struct WorkspacePanelView: View { let removed = store.existingRepositories.filter(\.isMarkedForRemoval).count let remaining = store.remainingRepositoryCount if store.mode.isEditing { + guard remaining > 0 else { + return String(localized: "A workspace keeps at least one repository. Undo a removal or add one.") + } guard added > 0 || removed > 0 else { return remaining == 1 ? String(localized: "Save keeps 1 repository.")