From 76e2f86f2798397e2c4dd0fcadee2f885c413ab7 Mon Sep 17 00:00:00 2001 From: Sunwenzhi58 <2514832692@qq.com> Date: Fri, 14 Aug 2026 20:08:24 +0800 Subject: [PATCH] fix: stage/unstage all files from the changes group checkbox The section header checkbox was rendered inside the collapse button, so clicking it only toggled the group instead of staging its files. Split it into a separate tri-state control and add a batch stage/unstage path through the Git service. Closes #19 Co-Authored-By: Claude Fable 5 --- .../Lithe/Application/GitFeatureModel.swift | 18 +++++ Sources/Lithe/Core/RustGitOperations.swift | 8 ++ Sources/Lithe/Models/AppModel.swift | 4 + Sources/Lithe/Services/GitService.swift | 10 +++ Sources/Lithe/Views/ChangesSidebarView.swift | 73 +++++++++++++------ 5 files changed, 89 insertions(+), 24 deletions(-) diff --git a/Sources/Lithe/Application/GitFeatureModel.swift b/Sources/Lithe/Application/GitFeatureModel.swift index 6c4c8d9a1..1223dbb95 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 e46d0bec7..09d9280ca 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 0e7bb258c..06b986d33 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 166d778bb..15728f64a 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 288cf92c8..26db76e4b 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 {