PadXcode-iPad/Git/GitPanel.swift
2026-04-12 22:07:35 -07:00

262 lines
12 KiB
Swift

import SwiftUI
import UIKit
struct GitPanel: View {
@ObservedObject var gitStore: GitStore
let gitService: GitService
@State private var tab: PanelTab = .changes
@State private var showCommit = false
@State private var showBranchPicker = false
@State private var showNewBranch = false
@State private var commitMessage = ""
@State private var newBranchName = ""
enum PanelTab: String, CaseIterable {
case changes = "Changes"; case history = "History"; case branches = "Branches"
var icon: String { switch self { case .changes: return "square.and.pencil"; case .history: return "clock"; case .branches: return "arrow.triangle.branch" } }
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack(spacing: 8) {
Menu {
ForEach(gitStore.repositories) { repo in
Button(repo.name) {
gitStore.activeRepo = repo.name
gitService.fetchStatus(repo: repo.name)
gitService.fetchBranches(repo: repo.name)
}
}
Divider()
Button { gitService.listRepositories() } label: { Label("Refresh Repos", systemImage: "arrow.clockwise") }
} label: {
HStack(spacing: 4) {
Image(systemName: "externaldrive.fill").font(.caption)
Text(gitStore.activeRepo.isEmpty ? "Select Repo" : gitStore.activeRepo)
.font(.system(.caption, design: .monospaced)).lineLimit(1)
Image(systemName: "chevron.down").font(.caption2)
}
.foregroundStyle(.primary)
}
Spacer()
Button { showBranchPicker = true; gitService.fetchBranches(repo: gitStore.activeRepo) } label: {
HStack(spacing: 3) {
Image(systemName: "arrow.triangle.branch").font(.caption)
Text(gitStore.currentBranch).font(.system(.caption, design: .monospaced)).lineLimit(1)
}
.padding(.horizontal, 6).padding(.vertical, 3)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 4))
}
.disabled(gitStore.activeRepo.isEmpty)
Button { gitService.pull(repo: gitStore.activeRepo) } label: { Image(systemName: "arrow.triangle.2.circlepath").font(.caption) }
.disabled(gitStore.activeRepo.isEmpty)
}
.padding(.horizontal, 10).padding(.vertical, 8)
Divider()
Picker("", selection: $tab) {
ForEach(PanelTab.allCases, id: \.self) { t in Label(t.rawValue, systemImage: t.icon).tag(t) }
}
.pickerStyle(.segmented).padding(.horizontal, 8).padding(.vertical, 6)
Divider()
switch tab {
case .changes: changesTab
case .history: historyTab
case .branches: branchesTab
}
}
.sheet(isPresented: $showCommit) {
commitSheet
}
.sheet(isPresented: $showBranchPicker) {
NavigationStack {
List(gitStore.branches) { branch in
Button {
showBranchPicker = false
gitService.checkout(repo: gitStore.activeRepo,
branch: branch.isRemote ? branch.localName : branch.name)
} label: {
HStack {
Image(systemName: branch.isRemote ? "cloud" : "arrow.triangle.branch").font(.caption).foregroundStyle(.secondary)
Text(branch.name).font(.system(.body, design: .monospaced))
Spacer()
if branch.name == gitStore.currentBranch { Image(systemName: "checkmark").foregroundStyle(Color.accentColor) }
}
}.buttonStyle(.plain)
}
.navigationTitle("Switch Branch").navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { showBranchPicker = false } } }
}
.presentationDetents([.medium])
}
.onReceive(NotificationCenter.default.publisher(for: .gitCheckoutCompleted)) { _ in
guard !gitStore.activeRepo.isEmpty else { return }
gitService.fetchBranches(repo: gitStore.activeRepo)
gitService.fetchStatus(repo: gitStore.activeRepo)
}
.onReceive(NotificationCenter.default.publisher(for: .gitCommitCompleted)) { _ in
guard !gitStore.activeRepo.isEmpty else { return }
gitService.fetchStatus(repo: gitStore.activeRepo)
}
}
// MARK: - Changes Tab
private var changesTab: some View {
VStack(spacing: 0) {
if gitStore.fileStatuses.isEmpty {
ContentUnavailableView("No Changes", systemImage: "checkmark.circle",
description: Text("Working tree is clean."))
} else {
List(gitStore.fileStatuses) { file in
HStack(spacing: 8) {
Image(systemName: file.statusIcon).font(.caption).foregroundStyle(file.statusSwiftUIColor).frame(width: 16)
VStack(alignment: .leading, spacing: 1) {
Text(file.name).font(.system(.body, design: .monospaced))
Text(file.path).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
Text({ switch file.status { case "modified": return "M"; case "added": return "A"; case "deleted": return "D"; default: return "?" } }())
.font(.system(.caption2, design: .monospaced).bold())
.foregroundStyle(file.statusSwiftUIColor)
}
}
.listStyle(.plain)
}
Divider()
HStack {
Button { gitService.fetchStatus(repo: gitStore.activeRepo) } label: { Image(systemName: "arrow.clockwise") }.buttonStyle(.plain)
Spacer()
Text("\(gitStore.fileStatuses.count) changed").font(.caption2).foregroundStyle(.secondary)
Spacer()
Button("Commit…") { showCommit = true }
.buttonStyle(.borderedProminent).controlSize(.small)
.disabled(gitStore.fileStatuses.isEmpty || gitStore.activeRepo.isEmpty)
}
.padding(.horizontal, 10).padding(.vertical, 8)
}
}
// MARK: - History Tab
private var historyTab: some View {
Group {
if gitStore.commitLog.isEmpty {
ContentUnavailableView("No History", systemImage: "clock",
description: Text("Tap refresh to load commits."))
.onAppear { if !gitStore.activeRepo.isEmpty { gitService.fetchLog(repo: gitStore.activeRepo) } }
} else {
List(gitStore.commitLog) { commit in
VStack(alignment: .leading, spacing: 3) {
Text(commit.summary).font(.subheadline).lineLimit(2)
HStack(spacing: 6) {
Text(commit.shortId).font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary)
.padding(.horizontal, 4).padding(.vertical, 1)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 3))
Text(commit.author.components(separatedBy: "<").first?.trimmingCharacters(in: .whitespaces) ?? commit.author)
.font(.caption).foregroundStyle(.secondary)
Spacer()
Text(commit.formattedDate).font(.caption2).foregroundStyle(.tertiary)
}
}
.padding(.vertical, 2)
}
.listStyle(.plain)
}
}
}
// MARK: - Branches Tab
private var branchesTab: some View {
List {
Section("Local") {
ForEach(gitStore.branches.filter { !$0.isRemote }) { b in
branchRow(b)
}
Button { showNewBranch = true } label: { Label("New Branch…", systemImage: "plus").font(.subheadline) }
}
let remotes = gitStore.branches.filter { $0.isRemote }
if !remotes.isEmpty {
Section("Remote") { ForEach(remotes) { b in branchRow(b) } }
}
}
.listStyle(.plain)
.sheet(isPresented: $showNewBranch) {
NavigationStack {
Form {
Section("Branch Name") {
TextField("feature/my-feature", text: $newBranchName)
.autocorrectionDisabled().autocapitalization(.none)
}
}
.navigationTitle("New Branch").navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { showNewBranch = false; newBranchName = "" } }
ToolbarItem(placement: .confirmationAction) {
Button("Create") {
gitService.createBranch(repo: gitStore.activeRepo, branch: newBranchName)
showNewBranch = false; newBranchName = ""
}
.disabled(newBranchName.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
.presentationDetents([.height(220)])
}
}
private func branchRow(_ branch: GitBranch) -> some View {
Button { gitService.checkout(repo: gitStore.activeRepo, branch: branch.isRemote ? branch.localName : branch.name) }
label: {
HStack {
Image(systemName: branch.isRemote ? "cloud" : "arrow.triangle.branch")
.font(.caption).foregroundStyle(branch.name == gitStore.currentBranch ? Color.accentColor : .secondary)
Text(branch.name).font(.system(.body, design: .monospaced))
.foregroundStyle(branch.name == gitStore.currentBranch ? Color.accentColor : .primary)
.bold(branch.name == gitStore.currentBranch)
Spacer()
if branch.name == gitStore.currentBranch { Text("current").font(.caption2).foregroundStyle(Color.accentColor) }
}
}
.buttonStyle(.plain)
}
// MARK: - Commit Sheet
private var commitSheet: some View {
NavigationStack {
Form {
Section("Changed Files (\(gitStore.fileStatuses.count))") {
ForEach(gitStore.fileStatuses) { f in
HStack(spacing: 6) {
Image(systemName: f.statusIcon).font(.caption).foregroundStyle(f.statusSwiftUIColor).frame(width: 16)
Text(f.name).font(.system(.caption, design: .monospaced))
}
}
}
Section("Commit Message") {
TextEditor(text: $commitMessage)
.font(.system(.body, design: .monospaced)).frame(minHeight: 80)
.autocorrectionDisabled().autocapitalization(.sentences)
}
Section {
Button("Commit & Push") {
gitService.commitAndPush(repo: gitStore.activeRepo, message: commitMessage)
commitMessage = ""; showCommit = false
}
.disabled(commitMessage.trimmingCharacters(in: .whitespaces).isEmpty)
Button("Commit Only") {
gitService.commit(repo: gitStore.activeRepo, message: commitMessage)
commitMessage = ""; showCommit = false
}
.disabled(commitMessage.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.navigationTitle("Commit Changes").navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { showCommit = false } } }
}
.presentationDetents([.medium, .large])
}
}