c118996746
- SwiftUI macOS app with C++17 code analysis engine (ObjC++ bridge) - Agentic AI loop: LLM plans → tool calls → execution → feedback loop - 15 agent tools: file ops, terminal, git, xcode build, code intel - 7 persistent terminal tools with background session management - Chat sidebar with agent step rendering and auto-apply - NVIDIA NIM API integration (Llama 3.3 70B default) - OpenAI tool_calls format with prompt-based fallback - Code editor with syntax highlighting and multi-tab support - File tree, console view, terminal view - Git integration and workspace management
255 lines
8.4 KiB
Swift
255 lines
8.4 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
// MARK: - Git Service
|
|
|
|
@MainActor
|
|
final class GitService: ObservableObject {
|
|
@Published var status: GitStatusSummary = GitStatusSummary()
|
|
@Published var branches: [GitBranch] = []
|
|
@Published var isGitRepo: Bool = false
|
|
@Published var isRefreshing: Bool = false
|
|
|
|
private var workingDirectory: URL?
|
|
|
|
func configure(workingDirectory: URL) {
|
|
self.workingDirectory = workingDirectory
|
|
Task { await checkIfGitRepo() }
|
|
}
|
|
|
|
// MARK: - Status
|
|
|
|
func refresh() async {
|
|
guard let dir = workingDirectory, isGitRepo else { return }
|
|
isRefreshing = true
|
|
defer { isRefreshing = false }
|
|
|
|
async let statusResult = fetchStatus(in: dir)
|
|
async let branchResult = fetchBranches(in: dir)
|
|
|
|
status = await statusResult
|
|
branches = await branchResult
|
|
}
|
|
|
|
// MARK: - Operations
|
|
|
|
func stageFile(_ path: String) async {
|
|
guard let dir = workingDirectory else { return }
|
|
_ = await runGit(["add", path], in: dir)
|
|
await refresh()
|
|
}
|
|
|
|
func unstageFile(_ path: String) async {
|
|
guard let dir = workingDirectory else { return }
|
|
_ = await runGit(["reset", "HEAD", path], in: dir)
|
|
await refresh()
|
|
}
|
|
|
|
func stageAll() async {
|
|
guard let dir = workingDirectory else { return }
|
|
_ = await runGit(["add", "-A"], in: dir)
|
|
await refresh()
|
|
}
|
|
|
|
func commit(message: String) async -> Bool {
|
|
guard let dir = workingDirectory, !message.isEmpty else { return false }
|
|
let result = await runGit(["commit", "-m", message], in: dir)
|
|
await refresh()
|
|
return result != nil
|
|
}
|
|
|
|
func clone(url: String, to destination: URL) async -> Bool {
|
|
let result = await runGit(["clone", url, destination.path], in: destination.deletingLastPathComponent())
|
|
if result != nil {
|
|
configure(workingDirectory: destination)
|
|
await refresh()
|
|
}
|
|
return result != nil
|
|
}
|
|
|
|
func discardChanges(_ path: String) async {
|
|
guard let dir = workingDirectory else { return }
|
|
_ = await runGit(["checkout", "--", path], in: dir)
|
|
await refresh()
|
|
}
|
|
|
|
func switchBranch(_ name: String) async -> Bool {
|
|
guard let dir = workingDirectory else { return false }
|
|
let result = await runGit(["checkout", name], in: dir)
|
|
await refresh()
|
|
return result != nil
|
|
}
|
|
|
|
func diff(for path: String) async -> String {
|
|
guard let dir = workingDirectory else { return "" }
|
|
return await runGit(["diff", path], in: dir) ?? ""
|
|
}
|
|
|
|
func log(count: Int = 20) async -> [GitLogEntry] {
|
|
guard let dir = workingDirectory else { return [] }
|
|
let format = "%H%n%an%n%ae%n%s%n%ci%n---"
|
|
guard let output = await runGit(
|
|
["log", "--format=\(format)", "-n", "\(count)"],
|
|
in: dir
|
|
) else { return [] }
|
|
|
|
var entries: [GitLogEntry] = []
|
|
let chunks = output.components(separatedBy: "\n---\n")
|
|
for chunk in chunks {
|
|
let lines = chunk.components(separatedBy: "\n").filter { !$0.isEmpty }
|
|
guard lines.count >= 4 else { continue }
|
|
entries.append(GitLogEntry(
|
|
hash: String(lines[0].prefix(8)),
|
|
author: lines[1],
|
|
email: lines[2],
|
|
message: lines[3],
|
|
date: lines.count > 4 ? lines[4] : ""
|
|
))
|
|
}
|
|
return entries
|
|
}
|
|
|
|
// MARK: - Internal
|
|
|
|
private func checkIfGitRepo() async {
|
|
guard let dir = workingDirectory else {
|
|
isGitRepo = false
|
|
return
|
|
}
|
|
let result = await runGit(["rev-parse", "--git-dir"], in: dir)
|
|
isGitRepo = result != nil
|
|
if isGitRepo {
|
|
await refresh()
|
|
}
|
|
}
|
|
|
|
private func fetchStatus(in dir: URL) async -> GitStatusSummary {
|
|
var summary = GitStatusSummary()
|
|
|
|
// Current branch
|
|
if let branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], in: dir) {
|
|
summary.branch = branch.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
// Ahead/behind
|
|
if let abOutput = await runGit(
|
|
["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
|
|
in: dir
|
|
) {
|
|
let parts = abOutput.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: "\t")
|
|
if parts.count == 2 {
|
|
summary.ahead = Int(parts[0]) ?? 0
|
|
summary.behind = Int(parts[1]) ?? 0
|
|
}
|
|
}
|
|
|
|
// File status
|
|
if let statusOutput = await runGit(["status", "--porcelain"], in: dir) {
|
|
let lines = statusOutput.components(separatedBy: "\n").filter { !$0.isEmpty }
|
|
for line in lines {
|
|
guard line.count >= 3 else { continue }
|
|
let statusCode = String(line.prefix(2))
|
|
let filePath = String(line.dropFirst(3))
|
|
|
|
let gitStatus: GitFileStatus
|
|
let isStaged: Bool
|
|
|
|
switch statusCode.trimmingCharacters(in: .whitespaces) {
|
|
case "M":
|
|
gitStatus = .modified
|
|
isStaged = statusCode.first != " "
|
|
case "A":
|
|
gitStatus = .added
|
|
isStaged = true
|
|
case "D":
|
|
gitStatus = .deleted
|
|
isStaged = statusCode.first != " "
|
|
case "R":
|
|
gitStatus = .renamed
|
|
isStaged = true
|
|
case "??":
|
|
gitStatus = .untracked
|
|
isStaged = false
|
|
case "UU", "AA", "DD":
|
|
gitStatus = .conflicted
|
|
isStaged = false
|
|
default:
|
|
gitStatus = .modified
|
|
isStaged = statusCode.first != " "
|
|
}
|
|
|
|
summary.files.append(GitFileChange(path: filePath, status: gitStatus, staged: isStaged))
|
|
|
|
switch gitStatus {
|
|
case .modified: summary.modified += 1
|
|
case .added: if isStaged { summary.staged += 1 }
|
|
case .untracked: summary.untracked += 1
|
|
case .conflicted: summary.conflicted += 1
|
|
default: break
|
|
}
|
|
}
|
|
}
|
|
|
|
return summary
|
|
}
|
|
|
|
private func fetchBranches(in dir: URL) async -> [GitBranch] {
|
|
guard let output = await runGit(["branch", "-a", "--no-color"], in: dir) else { return [] }
|
|
|
|
return output.components(separatedBy: "\n")
|
|
.filter { !$0.isEmpty }
|
|
.map { line in
|
|
let isCurrent = line.hasPrefix("*")
|
|
let name = line.trimmingCharacters(in: .whitespaces)
|
|
.replacingOccurrences(of: "* ", with: "")
|
|
.trimmingCharacters(in: .whitespaces)
|
|
let isRemote = name.hasPrefix("remotes/")
|
|
return GitBranch(
|
|
name: isRemote ? String(name.dropFirst("remotes/".count)) : name,
|
|
isCurrent: isCurrent,
|
|
isRemote: isRemote
|
|
)
|
|
}
|
|
}
|
|
|
|
private func runGit(_ arguments: [String], in directory: URL) async -> String? {
|
|
await withCheckedContinuation { continuation in
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
|
|
process.arguments = arguments
|
|
process.currentDirectoryURL = directory
|
|
|
|
let pipe = Pipe()
|
|
process.standardOutput = pipe
|
|
process.standardError = Pipe()
|
|
|
|
do {
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
|
|
if process.terminationStatus == 0 {
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
continuation.resume(returning: String(data: data, encoding: .utf8))
|
|
} else {
|
|
continuation.resume(returning: nil)
|
|
}
|
|
} catch {
|
|
continuation.resume(returning: nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Git Log
|
|
|
|
struct GitLogEntry: Identifiable {
|
|
let id = UUID()
|
|
let hash: String
|
|
let author: String
|
|
let email: String
|
|
let message: String
|
|
let date: String
|
|
}
|