From 62c8c08d0f01d8664b9e62bfb3e7ff61d33c931b Mon Sep 17 00:00:00 2001 From: Andrey Svinarenko Date: Sun, 31 May 2026 20:32:10 +0200 Subject: [PATCH 1/2] feat(changes): batch staging, auto-advance selection, and stash viewer --- CHANGELOG.md | 8 ++ .../AppUI/Selection/SelectionAdvance.swift | 30 +++++ Sources/AppUI/Stores/RepositoryStore.swift | 100 +++++++++++++- Sources/AppUI/Views/ChangeListView.swift | 76 +++++++---- Sources/AppUI/Views/HistoryView.swift | 2 +- .../AppUI/Views/RepositorySidebarView.swift | 25 +++- Sources/AppUI/Views/RepositoryView.swift | 11 ++ .../Views/StashContentsWorkspaceView.swift | 123 ++++++++++++++++++ Sources/GitKit/Git/CLIGitProvider.swift | 61 +++++++++ Sources/GitKit/Git/GitProviding.swift | 31 +++++ Tests/AppUITests/SelectionAdvanceTests.swift | 61 +++++++++ .../AppUITests/Support/FakeGitProvider.swift | 16 +++ Tests/GitKitTests/OperationsTests.swift | 52 ++++++++ 13 files changed, 564 insertions(+), 32 deletions(-) create mode 100644 Sources/AppUI/Selection/SelectionAdvance.swift create mode 100644 Sources/AppUI/Views/StashContentsWorkspaceView.swift create mode 100644 Tests/AppUITests/SelectionAdvanceTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d6910..aa6afda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ All notable changes to Avi are documented here. The format is based on ## [Unreleased] +### Added +- Click a stash in the sidebar to open its contents - the changed files and each file's diff - in the main pane, mirroring the commit detail view. +- The commit panel now shows a clear "Generating commit message..." indicator with a Cancel button while an AI commit message is being generated. + +### Changed +- Staging or unstaging several selected files now runs a single git command and one status refresh instead of one per file, so large selections are no longer slow. +- After staging or unstaging (from the pane button, the per-row +/- button, or the right-click menu), the selection moves to the next file in the list so you can keep working without re-selecting. + ## [0.1.2] - 2026-05-26 ### Added diff --git a/Sources/AppUI/Selection/SelectionAdvance.swift b/Sources/AppUI/Selection/SelectionAdvance.swift new file mode 100644 index 0000000..42e256a --- /dev/null +++ b/Sources/AppUI/Selection/SelectionAdvance.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Computes which path the selection should move to after some files leave a +/// pane (for example after staging or unstaging them). +/// +/// - Parameters: +/// - visibleOrder: the pane's file paths in the order they were displayed +/// *before* the action (flat order, or the flattened tree order). +/// - acted: the paths that were acted on and have now left the pane. +/// - surviving: the paths still present in the pane after the action. +/// - Returns: the nearest surviving neighbour AFTER the acted block; failing +/// that, the nearest surviving neighbour BEFORE it; `nil` if nothing is left +/// to select (the pane emptied). Mirrors the forward/backward neighbour walk +/// used by `RepositoryStore.preserveSelection`. +func nextSelection(visibleOrder: [String], acted: Set, surviving: Set) -> String? { + guard !acted.isEmpty else { return nil } + + let actedIndices = visibleOrder.indices.filter { acted.contains(visibleOrder[$0]) } + guard let firstActed = actedIndices.first, let lastActed = actedIndices.last else { return nil } + + if lastActed + 1 < visibleOrder.count, + let forward = visibleOrder[(lastActed + 1)...].first(where: { surviving.contains($0) }) { + return forward + } + if firstActed > 0, + let backward = visibleOrder[.., surviving: Set) async { + if let next = nextSelection(visibleOrder: visibleOrder, acted: acted, surviving: surviving), + let file = entries.first(where: { $0.path == next }) { + await select(file) + } else { + await select(nil) + } + } + public func discard(_ file: FileStatus) async { await perform { try await $0.discard(file, in: $1) } } @@ -560,6 +611,50 @@ public final class RepositoryStore: Identifiable { } } + /// Load a stash's changed files and select the first one. Mirrors `selectCommit`. + public func selectStash(ref: String?) async { + guard let root, let ref else { + clearStashSelection() + return + } + selectedStashRef = ref + stashFiles = [] + selectedStashPath = nil + stashFileDiff = nil + isStashLoading = true + defer { isStashLoading = false } + do { + stashFiles = try await git.stashChangedFiles(ref: ref, in: root) + await selectStashFile(stashFiles.first) + } catch { + errorMessage = error.localizedDescription + stashFiles = [] + stashFileDiff = nil + } + } + + public func selectStashFile(_ file: CommitFileChange?) async { + guard let root, let selectedStashRef, let file else { + selectedStashPath = nil + stashFileDiff = nil + return + } + selectedStashPath = file.path + do { + stashFileDiff = try await git.stashDiff(ref: selectedStashRef, path: file.path, in: root) + } catch { + errorMessage = error.localizedDescription + stashFileDiff = nil + } + } + + private func clearStashSelection() { + selectedStashRef = nil + stashFiles = [] + selectedStashPath = nil + stashFileDiff = nil + } + public func commit() async { guard let root, canCommit else { return } let message = commitMessage @@ -1303,10 +1398,7 @@ public final class RepositoryStore: Identifiable { private func stageGroup(_ group: AICommitGroup, in root: URL) async throws { guard !group.files.isEmpty else { return } - // Use stageAll-style call per file; the existing API is per-path. - for path in group.files { - try await git.stage(path: path, in: root) - } + try await git.stage(paths: group.files, in: root) } private func handleAIError(_ err: AIEngineError) { diff --git a/Sources/AppUI/Views/ChangeListView.swift b/Sources/AppUI/Views/ChangeListView.swift index e906d46..39cdb78 100644 --- a/Sources/AppUI/Views/ChangeListView.swift +++ b/Sources/AppUI/Views/ChangeListView.swift @@ -134,12 +134,7 @@ struct ChangeListView: View { actionTint: .accentColor, actionEnabled: !selectedUnstagedFiles.isEmpty ) { - let files = selectedUnstagedFiles - Task { - for file in files { - await store.stage(file) - } - } + stageFiles(selectedUnstagedFiles) } Divider() paneList(entries: store.unstagedEntries, staged: false, emptyText: "Nothing to stage") @@ -155,12 +150,7 @@ struct ChangeListView: View { actionTint: .secondary, actionEnabled: !selectedStagedFiles.isEmpty ) { - let files = selectedStagedFiles - Task { - for file in files { - await store.unstage(file) - } - } + unstageFiles(selectedStagedFiles) } Divider() paneList(entries: store.stagedEntries, staged: true, emptyText: "Nothing staged") @@ -177,6 +167,34 @@ struct ChangeListView: View { store.stagedEntries.filter { multiSelection.contains($0.path) } } + /// A pane's file paths in the order the user sees them: flat-list order, or + /// the flattened tree order honoring folder expansion. Passed to the store so + /// selection advances to the visually-next file after a stage/unstage. + private func visibleOrder(_ entries: [FileStatus]) -> [String] { + guard isTreeMode else { return entries.map(\.path) } + let tree = FileTreeBuilder.build(entries: entries) + return FileTreeBuilder.flatten(tree, expanded: store.expandedFolders).compactMap { node in + if case let .file(file) = node.payload { return file.path } + return nil + } + } + + /// Stage `files` as a single batch and advance selection to the next unstaged + /// file. Every stage entry point (pane button, row "+", context menu) funnels + /// through here. + private func stageFiles(_ files: [FileStatus]) { + guard !files.isEmpty else { return } + let order = visibleOrder(store.unstagedEntries) + Task { await store.stage(files, advancingFrom: order) } + } + + /// Unstage `files` as a single batch and advance selection to the next staged file. + private func unstageFiles(_ files: [FileStatus]) { + guard !files.isEmpty else { return } + let order = visibleOrder(store.stagedEntries) + Task { await store.unstage(files, advancingFrom: order) } + } + @ViewBuilder private func paneList(entries: [FileStatus], staged: Bool, emptyText: String) -> some View { ScrollViewReader { proxy in @@ -191,9 +209,15 @@ struct ChangeListView: View { treeRows(for: entries, staged: staged) } else { ForEach(entries) { file in - ChangeRow(file: file, staged: staged, store: store) - .tag(file.path) - .id(file.path) + ChangeRow( + file: file, + staged: staged, + store: store, + onStage: { stageFiles([$0]) }, + onUnstage: { unstageFiles([$0]) } + ) + .tag(file.path) + .id(file.path) } } } @@ -265,9 +289,15 @@ struct ChangeListView: View { .listRowInsets(EdgeInsets()) .listRowSeparator(.hidden) case .file(let file): - ChangeRow(file: file, staged: staged, store: store) - .tag(file.path) - .padding(.leading, CGFloat(node.depth + 1) * 12) + ChangeRow( + file: file, + staged: staged, + store: store, + onStage: { stageFiles([$0]) }, + onUnstage: { unstageFiles([$0]) } + ) + .tag(file.path) + .padding(.leading, CGFloat(node.depth + 1) * 12) } } } @@ -380,6 +410,8 @@ private struct ChangeRow: View { let file: FileStatus let staged: Bool let store: RepositoryStore + let onStage: (FileStatus) -> Void + let onUnstage: (FileStatus) -> Void @State private var confirmingDiscard = false var body: some View { @@ -394,11 +426,11 @@ private struct ChangeRow: View { Spacer(minLength: 6) if staged { inlineAction("minus.circle", help: "Unstage") { - Task { await store.unstage(file) } + onUnstage(file) } } else { inlineAction("plus.circle", help: "Stage") { - Task { await store.stage(file) } + onStage(file) } inlineAction("arrow.uturn.backward.circle", help: "Discard") { confirmingDiscard = true @@ -428,11 +460,11 @@ private struct ChangeRow: View { private var fileContextMenu: some View { if staged { Button("Unstage") { - Task { await store.unstage(file) } + onUnstage(file) } } else { Button("Stage") { - Task { await store.stage(file) } + onStage(file) } Button("Discard…", role: .destructive) { confirmingDiscard = true diff --git a/Sources/AppUI/Views/HistoryView.swift b/Sources/AppUI/Views/HistoryView.swift index c1ededc..c6d1ab9 100644 --- a/Sources/AppUI/Views/HistoryView.swift +++ b/Sources/AppUI/Views/HistoryView.swift @@ -608,7 +608,7 @@ private struct CommitFileListView: View { } } -private struct CommitFileRow: View { +struct CommitFileRow: View { let file: CommitFileChange var body: some View { diff --git a/Sources/AppUI/Views/RepositorySidebarView.swift b/Sources/AppUI/Views/RepositorySidebarView.swift index 773897f..7077073 100644 --- a/Sources/AppUI/Views/RepositorySidebarView.swift +++ b/Sources/AppUI/Views/RepositorySidebarView.swift @@ -244,7 +244,12 @@ struct RepositorySidebarView: View { if stashesExpanded { VStack(spacing: 1) { ForEach(filteredStashes) { entry in - StashRow(entry: entry, store: store) + StashRow( + entry: entry, + store: store, + isSelected: selection == .stash(ref: entry.ref), + select: { selection = .stash(ref: entry.ref) } + ) } if filteredStashes.isEmpty { EmptySectionRow( @@ -932,6 +937,8 @@ struct TagPopover: View { private struct StashRow: View { let entry: StashEntry let store: RepositoryStore + let isSelected: Bool + let select: () -> Void @State private var isHovering = false @State private var confirmingDrop = false @@ -941,15 +948,16 @@ private struct StashRow: View { Image(systemName: "tray") .font(.system(size: 10)) .frame(width: 12) - .foregroundStyle(.secondary) + .foregroundStyle(isSelected ? Color.white.opacity(0.9) : .secondary) Text("{\(entry.index)}") .font(.system(size: 10, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) + .foregroundStyle(isSelected ? Color.white.opacity(0.9) : .secondary) .frame(minWidth: 22, alignment: .leading) Text(displayMessage) .font(.system(size: 12)) + .foregroundStyle(isSelected ? .white : .primary) .lineLimit(1) .truncationMode(.tail) @@ -958,7 +966,7 @@ private struct StashRow: View { if let date = entry.date { Text(relative(date)) .font(.system(size: 10)) - .foregroundStyle(.tertiary) + .foregroundStyle(isSelected ? AnyShapeStyle(Color.white.opacity(0.75)) : AnyShapeStyle(.tertiary)) .lineLimit(1) } } @@ -966,9 +974,10 @@ private struct StashRow: View { .frame(height: 22) .background( RoundedRectangle(cornerRadius: 6) - .fill(isHovering ? Color.primary.opacity(0.06) : Color.clear) + .fill(rowFill) ) .contentShape(Rectangle()) + .onTapGesture(perform: select) .onHover { isHovering = $0 } .help(entry.subject) .contextMenu { @@ -999,6 +1008,12 @@ private struct StashRow: View { } } + private var rowFill: Color { + if isSelected { return Color.accentColor } + if isHovering { return Color.primary.opacity(0.05) } + return Color.clear + } + private var displayMessage: String { // The reflog subject typically looks like "WIP on main: abc1234 actual commit message" // or "On feature/x: user-supplied message". Strip the prefix so the row shows diff --git a/Sources/AppUI/Views/RepositoryView.swift b/Sources/AppUI/Views/RepositoryView.swift index 169b866..8ff6fe5 100644 --- a/Sources/AppUI/Views/RepositoryView.swift +++ b/Sources/AppUI/Views/RepositoryView.swift @@ -57,6 +57,13 @@ struct RepositoryView: View { .onChange(of: store.entries.isEmpty) { _, _ in applyInitialSelectionIfNeeded() } + .onChange(of: store.stashes) { _, stashes in + // If the open stash was popped or dropped, fall back to local changes + // so the workspace doesn't point at a stash that no longer exists. + if case let .stash(ref)? = selection, !stashes.contains(where: { $0.ref == ref }) { + selection = .localChanges + } + } .onReceive(NotificationCenter.default.publisher(for: .aviCreateBranch)) { notification in createBranchStartPoint = notification.object as? String showingCreateBranch = true @@ -203,6 +210,8 @@ struct RepositoryView: View { store: store, switchToAllCommits: { selection = .allCommits } ) + case let .stash(ref): + StashContentsWorkspaceView(store: store, ref: ref) case .allCommits, .branch, .remoteBranch, .tag: HistoryWorkspaceView(store: store) } @@ -245,11 +254,13 @@ enum RepositorySelection: Hashable { case branch(name: String) case remoteBranch(name: String) case tag(name: String) + case stash(ref: String) var persisted: PersistedView? { switch self { case .localChanges: return .localChanges case .allCommits, .branch, .remoteBranch, .tag: return .allCommits + case .stash: return nil } } } diff --git a/Sources/AppUI/Views/StashContentsWorkspaceView.swift b/Sources/AppUI/Views/StashContentsWorkspaceView.swift new file mode 100644 index 0000000..420bf9b --- /dev/null +++ b/Sources/AppUI/Views/StashContentsWorkspaceView.swift @@ -0,0 +1,123 @@ +import GitKit +import SwiftUI + +/// Shows the contents of a stash - its changed files and the per-file diff - +/// when a stash is selected in the sidebar. A stash is commit-like, so this +/// mirrors `CommitDetailView` and reuses `CommitFileRow` and `FileDiffView`. +struct StashContentsWorkspaceView: View { + let store: RepositoryStore + let ref: String + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + HSplitView { + StashFileListView(store: store) + .frame(minWidth: 220, idealWidth: 280) + + if let file = store.selectedStashFile { + FileDiffView(title: file.displayPath, diff: store.stashFileDiff) + .frame(minWidth: 420) + } else { + ContentUnavailableView( + "No File Selected", + systemImage: "doc.text", + description: Text("Select a changed file to see its diff.") + ) + .frame(minWidth: 420) + } + } + } + .task(id: ref) { + await store.selectStash(ref: ref) + } + } + + private var entry: StashEntry? { + store.stashes.first { $0.ref == ref } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Image(systemName: "tray") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Text(entry.map { "{\($0.index)}" } ?? ref) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + Text(title) + .font(.system(size: 14, weight: .semibold)) + .lineLimit(2) + .textSelection(.enabled) + } + if let branch = entry?.branch { + Text("on \(branch)") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// Strip the "WIP on : " / "On : " reflog prefix so the + /// header shows the meaningful message, matching the sidebar StashRow. + private var title: String { + guard let subject = entry?.subject else { return ref } + if let colon = subject.firstIndex(of: ":") { + let after = subject[subject.index(after: colon)...].trimmingCharacters(in: .whitespaces) + if !after.isEmpty { return after } + } + return subject + } +} + +private struct StashFileListView: View { + let store: RepositoryStore + + var body: some View { + VStack(spacing: 0) { + PanelHeader(title: "Files", trailing: filesSummary) + Divider() + List(selection: selection) { + ForEach(store.stashFiles) { file in + CommitFileRow(file: file) + .tag(file.path) + } + } + .scrollContentBackground(.hidden) + .overlay { + if store.stashFiles.isEmpty { + if store.isStashLoading { + ProgressView() + } else { + ContentUnavailableView( + "No Changes", + systemImage: "tray", + description: Text("This stash has no tracked changes to show.") + ) + } + } + } + } + } + + private var filesSummary: String? { + let n = store.stashFiles.count + if n == 0 { return nil } + return n == 1 ? "1 file" : "\(n) files" + } + + private var selection: Binding { + Binding( + get: { store.selectedStashPath }, + set: { newValue in + let file = store.stashFiles.first { $0.path == newValue } + Task { await store.selectStashFile(file) } + } + ) + } +} diff --git a/Sources/GitKit/Git/CLIGitProvider.swift b/Sources/GitKit/Git/CLIGitProvider.swift index ebee8bc..dc109d7 100644 --- a/Sources/GitKit/Git/CLIGitProvider.swift +++ b/Sources/GitKit/Git/CLIGitProvider.swift @@ -1,5 +1,16 @@ import Foundation +private extension Array { + /// Split into consecutive chunks of at most `size` elements. Used to keep + /// batched `git` argument lists under the OS argument-length limit. + func chunked(into size: Int) -> [[Element]] { + guard size > 0 else { return [self] } + return stride(from: 0, to: count, by: size).map { + Array(self[$0 ..< Swift.min($0 + size, count)]) + } + } +} + /// `GitProviding` implementation that shells out to the `git` CLI. public struct CLIGitProvider: GitProviding { public let gitURL: URL @@ -342,6 +353,17 @@ public struct CLIGitProvider: GitProviding { try await run(["add", "--", path], in: repository) } + public func stage(paths: [String], in repository: URL) async throws { + guard !paths.isEmpty else { return } + // One `git add` per chunk keeps the argument list well under ARG_MAX for + // very large multi-selections, while still avoiding the per-file + // subprocess (and per-file status refresh) that made staging many files + // slow. + for chunk in paths.chunked(into: 256) { + try await run(["add", "--"] + chunk, in: repository) + } + } + public func stageAll(in repository: URL) async throws { try await run(["add", "--all"], in: repository) } @@ -350,6 +372,13 @@ public struct CLIGitProvider: GitProviding { try await run(["restore", "--staged", "--", path], in: repository) } + public func unstage(paths: [String], in repository: URL) async throws { + guard !paths.isEmpty else { return } + for chunk in paths.chunked(into: 256) { + try await run(["restore", "--staged", "--"] + chunk, in: repository) + } + } + public func unstageAll(in repository: URL) async throws { try await run(["restore", "--staged", "--", "."], in: repository) } @@ -655,6 +684,38 @@ public struct CLIGitProvider: GitProviding { try await run(["stash", "drop", ref], in: repository) } + public func stashChangedFiles(ref: String, in repository: URL) async throws -> [CommitFileChange] { + // A stash commit has multiple parents (base, index, optionally untracked), + // so `diff-tree`/`show` on it prints nothing by default. Diff the stash + // against its first parent - the commit it was taken from - to list the + // files it carries. Untracked-only entries (parent `^3`) aren't covered. + let result = try await run([ + "diff", + "--name-status", + "-z", + "-M", + "-C", + "\(ref)^1", + ref + ], in: repository) + return try CommitFileChangeParser.parse(result.stdout) + } + + public func stashDiff(ref: String, path: String, in repository: URL) async throws -> FileDiff { + let result = try await run([ + "diff", + "--no-color", + "--no-ext-diff", + "-M", + "-C", + "\(ref)^1", + ref, + "--", + path + ], in: repository) + return DiffParser.parse(result.stdoutString) + } + // MARK: - Internal helpers /// Returns the upstream of HEAD as `/` (e.g. `origin/main`), diff --git a/Sources/GitKit/Git/GitProviding.swift b/Sources/GitKit/Git/GitProviding.swift index 2d4056d..c0d19fa 100644 --- a/Sources/GitKit/Git/GitProviding.swift +++ b/Sources/GitKit/Git/GitProviding.swift @@ -119,12 +119,21 @@ public protocol GitProviding: Sendable { /// Stage `path` (`git add`). func stage(path: String, in repository: URL) async throws + /// Stage multiple paths in a single `git add` invocation. Has a default + /// implementation that loops the single-path variant; the CLI provider + /// overrides it with one batched call. + func stage(paths: [String], in repository: URL) async throws + /// Stage all tracked and untracked working-copy changes. func stageAll(in repository: URL) async throws /// Unstage `path`, keeping working-tree changes (`git restore --staged`). func unstage(path: String, in repository: URL) async throws + /// Unstage multiple paths in a single `git restore --staged` invocation. + /// Defaults to looping the single-path variant; CLI provider batches. + func unstage(paths: [String], in repository: URL) async throws + /// Unstage all staged changes, keeping working-tree changes. func unstageAll(in repository: URL) async throws @@ -190,6 +199,13 @@ public protocol GitProviding: Sendable { /// `git stash drop `. Deletes the stash without applying it. func dropStash(ref: String, in repository: URL) async throws + + /// Files changed by a stash, diffed against its first parent (`^1 `). + /// A stash is a merge commit, so the plain commit helpers can't be reused. + func stashChangedFiles(ref: String, in repository: URL) async throws -> [CommitFileChange] + + /// Unified diff for one file inside a stash (`^1 -- `). + func stashDiff(ref: String, path: String, in repository: URL) async throws -> FileDiff } public enum GitResetMode: String, Sendable { @@ -223,4 +239,19 @@ public extension GitProviding { ) async throws -> GitRemoteOperationResult { try await push(branch: branch, in: repository) } + + /// Default: stage paths one at a time. The CLI provider overrides this with a + /// single batched `git add` so large multi-selections don't spawn N processes. + func stage(paths: [String], in repository: URL) async throws { + for path in paths { + try await stage(path: path, in: repository) + } + } + + /// Default: unstage paths one at a time. The CLI provider batches. + func unstage(paths: [String], in repository: URL) async throws { + for path in paths { + try await unstage(path: path, in: repository) + } + } } diff --git a/Tests/AppUITests/SelectionAdvanceTests.swift b/Tests/AppUITests/SelectionAdvanceTests.swift new file mode 100644 index 0000000..ddbd225 --- /dev/null +++ b/Tests/AppUITests/SelectionAdvanceTests.swift @@ -0,0 +1,61 @@ +@testable import AppUI +import Testing + +struct SelectionAdvanceTests { + struct Case: Sendable { + let name: String + let order: [String] + let acted: Set + let surviving: Set + let expected: String? + } + + static let cases: [Case] = [ + Case( + name: "middle file advances to the next one below", + order: ["a", "b", "c", "d"], acted: ["b"], surviving: ["a", "c", "d"], expected: "c" + ), + Case( + name: "first file advances to the next", + order: ["a", "b", "c"], acted: ["a"], surviving: ["b", "c"], expected: "b" + ), + Case( + name: "last file falls back to the previous one", + order: ["a", "b", "c"], acted: ["c"], surviving: ["a", "b"], expected: "b" + ), + Case( + name: "acting on everything clears the selection", + order: ["a", "b"], acted: ["a", "b"], surviving: [], expected: nil + ), + Case( + name: "a contiguous block advances to the file after it", + order: ["a", "b", "c", "d"], acted: ["b", "c"], surviving: ["a", "d"], expected: "d" + ), + Case( + name: "a trailing block falls back above the block", + order: ["a", "b", "c", "d"], acted: ["c", "d"], surviving: ["a", "b"], expected: "b" + ), + Case( + name: "scattered selection anchors on the last acted index", + order: ["a", "b", "c", "d", "e"], acted: ["b", "d"], surviving: ["a", "c", "e"], expected: "e" + ), + Case( + name: "a non-surviving neighbour is skipped", + order: ["a", "b", "c", "d"], acted: ["a"], surviving: ["c", "d"], expected: "c" + ), + Case( + name: "empty acted set yields nil", + order: ["a", "b"], acted: [], surviving: ["a", "b"], expected: nil + ) + ] + + @Test(arguments: cases) + func advancesToExpectedNeighbour(_ testCase: Case) { + let result = nextSelection( + visibleOrder: testCase.order, + acted: testCase.acted, + surviving: testCase.surviving + ) + #expect(result == testCase.expected, "\(testCase.name)") + } +} diff --git a/Tests/AppUITests/Support/FakeGitProvider.swift b/Tests/AppUITests/Support/FakeGitProvider.swift index 6a5f0aa..f7a64b6 100644 --- a/Tests/AppUITests/Support/FakeGitProvider.swift +++ b/Tests/AppUITests/Support/FakeGitProvider.swift @@ -13,6 +13,12 @@ public final class FakeGitProvider: GitProviding, @unchecked Sendable { public var commitFiles: [String: [CommitFileChange]] public var lastCommit: String? + /// Recorded calls for batched staging assertions in tests. + public private(set) var stagePathsCalls: [[String]] = [] + public private(set) var unstagePathsCalls: [[String]] = [] + /// Stash changed-files keyed by stash ref, for stash-content tests. + public var stashChanges: [String: [CommitFileChange]] = [:] + public init( status: WorkingCopyStatus, refs: RepositoryRefs = .empty, @@ -94,8 +100,10 @@ public final class FakeGitProvider: GitProviding, @unchecked Sendable { } public func stage(path _: String, in _: URL) async throws {} + public func stage(paths: [String], in _: URL) async throws { stagePathsCalls.append(paths) } public func stageAll(in _: URL) async throws {} public func unstage(path _: String, in _: URL) async throws {} + public func unstage(paths: [String], in _: URL) async throws { unstagePathsCalls.append(paths) } public func unstageAll(in _: URL) async throws {} public func discard(_: FileStatus, in _: URL) async throws {} public func commit(message _: String, in _: URL) async throws {} @@ -157,4 +165,12 @@ public final class FakeGitProvider: GitProviding, @unchecked Sendable { public func applyStash(ref _: String, in _: URL) async throws {} public func popStash(ref _: String, in _: URL) async throws {} public func dropStash(ref _: String, in _: URL) async throws {} + + public func stashChangedFiles(ref: String, in _: URL) async throws -> [CommitFileChange] { + stashChanges[ref] ?? [] + } + + public func stashDiff(ref _: String, path: String, in _: URL) async throws -> FileDiff { + fileDiffs[path] ?? FileDiff(hunks: [], isBinary: false) + } } diff --git a/Tests/GitKitTests/OperationsTests.swift b/Tests/GitKitTests/OperationsTests.swift index 8df781d..3406ae5 100644 --- a/Tests/GitKitTests/OperationsTests.swift +++ b/Tests/GitKitTests/OperationsTests.swift @@ -35,6 +35,58 @@ struct OperationsTests { } } + @Test func stagePathsStagesEverySelectedFileInOneCall() async throws { + try await withTempRepo { repo in + try repo.write("a.txt", "1\n") + try repo.write("b.txt", "2\n") + try repo.write("c.txt", "3\n") + + try await provider(repo).stage(paths: ["a.txt", "b.txt", "c.txt"], in: repo.url) + + let staged = try await provider(repo).status(in: repo.url) + .entries.filter(\.isStaged).map(\.path).sorted() + #expect(staged == ["a.txt", "b.txt", "c.txt"]) + } + } + + @Test func unstagePathsUnstagesEverySelectedFile() async throws { + try await withTempRepo { repo in + try repo.write("a.txt", "v1\n") + try repo.write("b.txt", "v1\n") + try await repo.git("add", "a.txt", "b.txt") + try await repo.git("commit", "-q", "-m", "init") + try repo.write("a.txt", "v2\n") + try repo.write("b.txt", "v2\n") + try await repo.git("add", "a.txt", "b.txt") + + try await provider(repo).unstage(paths: ["a.txt", "b.txt"], in: repo.url) + + let status = try await provider(repo).status(in: repo.url) + for path in ["a.txt", "b.txt"] { + let entry = try #require(status.entries.first { $0.path == path }) + #expect(entry.index == .unmodified) + #expect(entry.worktree == .modified) + } + } + } + + @Test func stashContentsListAndDiffChangedFiles() async throws { + try await withTempRepo { repo in + try repo.write("a.txt", "v1\n") + try await repo.git("add", "a.txt") + try await repo.git("commit", "-q", "-m", "init") + try repo.write("a.txt", "v2\n") + try await repo.git("stash", "push", "-m", "wip") + + let files = try await provider(repo).stashChangedFiles(ref: "stash@{0}", in: repo.url) + #expect(files.map(\.path) == ["a.txt"]) + + let diff = try await provider(repo).stashDiff(ref: "stash@{0}", path: "a.txt", in: repo.url) + #expect(!diff.isBinary) + #expect(!diff.hunks.isEmpty) + } + } + @Test func unstageKeepsWorkingTreeChange() async throws { try await withTempRepo { repo in try repo.write("a.txt", "v1\n") From d1488cb179f3bd9407c83a4eb61ff0bc725d2cb7 Mon Sep 17 00:00:00 2001 From: Andrey Svinarenko Date: Sun, 31 May 2026 20:32:10 +0200 Subject: [PATCH 2/2] feat(commit): show a visible AI commit generation loading state --- Sources/AppUI/Views/CommitPanelView.swift | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Sources/AppUI/Views/CommitPanelView.swift b/Sources/AppUI/Views/CommitPanelView.swift index 4d016f7..a0a6cc2 100644 --- a/Sources/AppUI/Views/CommitPanelView.swift +++ b/Sources/AppUI/Views/CommitPanelView.swift @@ -14,6 +14,10 @@ struct CommitPanelView: View { VStack(alignment: .leading, spacing: 6) { header + if store.isGeneratingCommitMessage { + generatingBanner + } + if let preview = store.aiPendingPreview { AIPreviewCard(preview: preview, store: store, config: config.config.ai) } @@ -44,6 +48,7 @@ struct CommitPanelView: View { } .animation(Glass.Motion.snappy, value: store.aiDebugDrawerVisible) .animation(Glass.Motion.snappy, value: store.aiDebugMinimized) + .animation(Glass.Motion.snappy, value: store.isGeneratingCommitMessage) .task(id: store.amend) { await store.prepareAmendIfNeeded() } @@ -99,6 +104,30 @@ struct CommitPanelView: View { } } + /// Prominent in-panel indicator so the user can see AI generation is running + /// without opening the AI menu or the debug drawer. Styled like AIPreviewCard. + private var generatingBanner: some View { + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text("Generating commit message…") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + Spacer() + Button("Cancel") { + store.cancelCommitMessageGeneration() + } + .buttonStyle(.plain) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.accentColor.opacity(0.08)) + ) + } + /// Single AI menu - explicit actions, no auto-magic. The user picks what /// they want the AI to do; we do exactly that and nothing else. private var aiMenu: some View {