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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions Sources/AppUI/Selection/SelectionAdvance.swift
Original file line number Diff line number Diff line change
@@ -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<String>, surviving: Set<String>) -> 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[..<firstActed].last(where: { surviving.contains($0) }) {
return backward
}
return nil
}
100 changes: 96 additions & 4 deletions Sources/AppUI/Stores/RepositoryStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ public final class RepositoryStore: Identifiable {
public private(set) var commitFiles: [CommitFileChange] = []
public private(set) var selectedCommitPath: String?
public private(set) var commitDiff: FileDiff?
public private(set) var selectedStashRef: String?
public private(set) var stashFiles: [CommitFileChange] = []
public private(set) var selectedStashPath: String?
public private(set) var stashFileDiff: FileDiff?
public private(set) var isStashLoading = false
public private(set) var refs: RepositoryRefs = .empty
public private(set) var stashes: [StashEntry] = []
public private(set) var remotes: [GitRemote] = []
Expand Down Expand Up @@ -89,6 +94,10 @@ public final class RepositoryStore: Identifiable {
commitFiles.first { $0.path == selectedCommitPath }
}

public var selectedStashFile: CommitFileChange? {
stashFiles.first { $0.path == selectedStashPath }
}

public var canAmend: Bool {
branch?.isUnborn == false
}
Expand Down Expand Up @@ -232,6 +241,48 @@ public final class RepositoryStore: Identifiable {
await perform { try await $0.unstageAll(in: $1) }
}

/// Stage `files` in one batched `git add`, refresh once, then advance the
/// selection to the next file in `visibleOrder` (the unstaged pane's visible
/// order captured before the action). The view supplies the order because
/// tree-mode ordering depends on folder-expansion state the store doesn't track.
public func stage(_ files: [FileStatus], advancingFrom visibleOrder: [String]) async {
guard !files.isEmpty, let root else { return }
let acted = Set(files.map(\.path))
do {
try await git.stage(paths: files.map(\.path), in: root)
await refresh()
await advanceSelection(visibleOrder: visibleOrder, acted: acted, surviving: Set(unstagedEntries.map(\.path)))
} catch {
errorMessage = error.localizedDescription
}
}

/// Unstage `files` in one batched `git restore --staged`, refresh once, then
/// advance the selection to the next file in the staged pane's `visibleOrder`.
public func unstage(_ files: [FileStatus], advancingFrom visibleOrder: [String]) async {
guard !files.isEmpty, let root else { return }
let acted = Set(files.map(\.path))
do {
try await git.unstage(paths: files.map(\.path), in: root)
await refresh()
await advanceSelection(visibleOrder: visibleOrder, acted: acted, surviving: Set(stagedEntries.map(\.path)))
} catch {
errorMessage = error.localizedDescription
}
}

/// Pick the next file to select after a batched stage/unstage. Setting
/// `selectedPath` drives the change list's highlight + diff via its existing
/// `onChange(of: store.selectedPath)` sync.
private func advanceSelection(visibleOrder: [String], acted: Set<String>, surviving: Set<String>) 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) }
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
76 changes: 54 additions & 22 deletions Sources/AppUI/Views/ChangeListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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
Expand All @@ -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)
}
}
}
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions Sources/AppUI/Views/CommitPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion Sources/AppUI/Views/HistoryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,7 @@ private struct CommitFileListView: View {
}
}

private struct CommitFileRow: View {
struct CommitFileRow: View {
let file: CommitFileChange

var body: some View {
Expand Down
Loading
Loading