Skip to content
Closed
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
18 changes: 18 additions & 0 deletions Sources/Lithe/Application/GitFeatureModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
8 changes: 8 additions & 0 deletions Sources/Lithe/Core/RustGitOperations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/Lithe/Models/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
10 changes: 10 additions & 0 deletions Sources/Lithe/Services/GitService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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) }
}
Expand Down
73 changes: 49 additions & 24 deletions Sources/Lithe/Views/ChangesSidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Loading