diff --git a/Sources/Lithe/Application/GitFeatureModel.swift b/Sources/Lithe/Application/GitFeatureModel.swift index 6c4c8d9a..1223dbb9 100644 --- a/Sources/Lithe/Application/GitFeatureModel.swift +++ b/Sources/Lithe/Application/GitFeatureModel.swift @@ -603,6 +603,24 @@ final class GitFeatureModel: ObservableObject { await refreshGit() } + /// Stages or unstages every file in a section (for example the "Changes" or + /// "Unversioned Files" group header checkbox). Mirrors the tri-state behavior + /// of the JetBrains changelist checkbox: if every file is already staged the + /// group is unstaged, otherwise the group is staged to completion. + func toggleStagingAll(_ changes: [GitChange]) async { + guard let gitRepositoryRoot, !changes.isEmpty else { return } + let shouldStage = !changes.allSatisfy(\.isStaged) + let paths = changes.flatMap(\.pathspecs) + let result = await withGitOperation { + shouldStage + ? await service.stage(paths: paths, at: gitRepositoryRoot) + : await service.unstage(paths: paths, at: gitRepositoryRoot) + } + let verb = shouldStage ? "Staged" : "Unstaged" + showResult(result, success: "\(verb) \(changes.count) file(s)") + await refreshGit() + } + func stageAllChanges() async { guard let gitRepositoryRoot else { return } let result = await withGitOperation { await service.stageAll(at: gitRepositoryRoot) } diff --git a/Sources/Lithe/Core/RustGitOperations.swift b/Sources/Lithe/Core/RustGitOperations.swift index e46d0bec..09d9280c 100644 --- a/Sources/Lithe/Core/RustGitOperations.swift +++ b/Sources/Lithe/Core/RustGitOperations.swift @@ -89,6 +89,14 @@ struct RustGitOperations: GitOperations, GitCommandRunner, Sendable { write(at: change.repositoryRoot, operation: "unstage", paths: change.pathspecs) } + func stage(paths: [String], at rootURL: URL) -> ProcessResult? { + write(at: rootURL, operation: "stage", paths: paths) + } + + func unstage(paths: [String], at rootURL: URL) -> ProcessResult? { + write(at: rootURL, operation: "unstage", paths: paths) + } + func discard(_ change: GitChange) -> ProcessResult? { return write(at: change.repositoryRoot, operation: "discard", paths: change.pathspecs) } diff --git a/Sources/Lithe/Models/AppModel.swift b/Sources/Lithe/Models/AppModel.swift index 0e7bb258..06b986d3 100644 --- a/Sources/Lithe/Models/AppModel.swift +++ b/Sources/Lithe/Models/AppModel.swift @@ -1403,6 +1403,10 @@ final class AppModel: ObservableObject, Identifiable { await gitFeature.toggleStaging(change) } + func toggleStagingAll(_ changes: [GitChange]) async { + await gitFeature.toggleStagingAll(changes) + } + func stageAllChanges() async { await gitFeature.stageAllChanges() } diff --git a/Sources/Lithe/Services/GitService.swift b/Sources/Lithe/Services/GitService.swift index 166d778b..15728f64 100644 --- a/Sources/Lithe/Services/GitService.swift +++ b/Sources/Lithe/Services/GitService.swift @@ -55,6 +55,8 @@ protocol GitOperations: Sendable { func stage(_ change: GitChange) -> ProcessResult? func unstage(_ change: GitChange) -> ProcessResult? + func stage(paths: [String], at rootURL: URL) -> ProcessResult? + func unstage(paths: [String], at rootURL: URL) -> ProcessResult? func discard(_ change: GitChange) -> ProcessResult? func discardAll(_ change: GitChange) -> ProcessResult? func commit(at rootURL: URL, message: String, amend: Bool) -> ProcessResult? @@ -232,6 +234,14 @@ struct GitService: GitWatchContextProviding, Sendable { await command { $0.unstage(change) } } + func stage(paths: [String], at repositoryRoot: URL) async -> CommandResult { + await command { $0.stage(paths: paths, at: repositoryRoot) } + } + + func unstage(paths: [String], at repositoryRoot: URL) async -> CommandResult { + await command { $0.unstage(paths: paths, at: repositoryRoot) } + } + func discard(_ change: GitChange) async -> CommandResult { return await command { $0.discard(change) } } diff --git a/Sources/Lithe/Views/ChangesSidebarView.swift b/Sources/Lithe/Views/ChangesSidebarView.swift index 288cf92c..26db76e4 100644 --- a/Sources/Lithe/Views/ChangesSidebarView.swift +++ b/Sources/Lithe/Views/ChangesSidebarView.swift @@ -521,33 +521,47 @@ struct ChangesSidebarView: View { showsParentPaths: Bool ) -> some View { if !changes.isEmpty { - Button { - expanded.wrappedValue.toggle() - } label: { - HStack(spacing: 7) { - Image(systemName: expanded.wrappedValue ? "chevron.down" : "chevron.right") - .font(.system(size: 8, weight: .bold)) - .frame(width: 10) - Image(systemName: "square") + HStack(spacing: 0) { + Button { + Task { await model.toggleStagingAll(changes) } + } label: { + Image(systemName: sectionStagingSymbol(changes)) .font(.system(size: 16)) - .foregroundStyle(LitheTheme.secondaryText) - Text(LocalizedStringKey(title)) - .font(.system(size: 12.5, weight: .semibold)) - .foregroundStyle(LitheTheme.primaryText) - Text(changes.count == 1 ? "1 file" : "\(changes.count) files") - .font(.system(size: 11)) - .foregroundStyle(LitheTheme.secondaryText) - Spacer() + .foregroundStyle(sectionStagingColor(changes)) + .frame(width: 24, height: 30) + .contentShape(Rectangle()) } - .padding(.horizontal, 7) - .frame(maxWidth: .infinity) - .frame(height: 30) - .background(LitheTheme.subtleSelection.opacity(0.72)) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .contentShape(Rectangle()) + .buttonStyle(.plain) + .lithePointer() + .help("Stage or unstage all files in this group") + + Button { + expanded.wrappedValue.toggle() + } label: { + HStack(spacing: 7) { + Image(systemName: expanded.wrappedValue ? "chevron.down" : "chevron.right") + .font(.system(size: 8, weight: .bold)) + .frame(width: 10) + Text(LocalizedStringKey(title)) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + Text(changes.count == 1 ? "1 file" : "\(changes.count) files") + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + .frame(height: 30) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .lithePointer() } - .buttonStyle(.plain) - .lithePointer() + .padding(.horizontal, 7) + .frame(maxWidth: .infinity) + .frame(height: 30) + .background(LitheTheme.subtleSelection.opacity(0.72)) + .clipShape(RoundedRectangle(cornerRadius: 4)) if expanded.wrappedValue { ForEach(changes) { change in @@ -557,6 +571,17 @@ struct ChangesSidebarView: View { } } + private func sectionStagingSymbol(_ changes: [GitChange]) -> String { + let staged = changes.filter(\.isStaged).count + if staged == changes.count { return "checkmark.square.fill" } + if staged == 0 { return "square" } + return "minus.square" + } + + private func sectionStagingColor(_ changes: [GitChange]) -> Color { + changes.allSatisfy(\.isStaged) ? LitheTheme.accent : LitheTheme.secondaryText + } + private func changeRow(_ change: GitChange, showsParentPath: Bool) -> some View { HStack(spacing: 6) { Button {