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
559 lines
18 KiB
Swift
559 lines
18 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - File Tree View
|
|
|
|
struct FileTreeView: View {
|
|
@ObservedObject var workspaceService: WorkspaceService
|
|
@ObservedObject var viewModel: EditorViewModel
|
|
@State private var contextMenuNode: FileNode?
|
|
@State private var newItemName: String = ""
|
|
@State private var isCreatingFile: Bool = false
|
|
@State private var isCreatingFolder: Bool = false
|
|
@State private var renamingNode: FileNode?
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
treeHeader
|
|
Divider()
|
|
|
|
if workspaceService.isLoading {
|
|
loadingView
|
|
} else if workspaceService.fileTree.isEmpty {
|
|
emptyView
|
|
} else {
|
|
fileList
|
|
}
|
|
}
|
|
.background(viewModel.currentTheme.sidebarBackground)
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
private var treeHeader: some View {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "folder.fill")
|
|
.foregroundColor(.blue)
|
|
.font(.caption)
|
|
Text(workspaceService.currentWorkspace?.name.uppercased() ?? "EXPLORER")
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundColor(.gray)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
|
|
Button(action: { isCreatingFile = true }) {
|
|
Image(systemName: "doc.badge.plus")
|
|
.font(.caption)
|
|
.foregroundColor(.gray)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("New File")
|
|
|
|
Button(action: { isCreatingFolder = true }) {
|
|
Image(systemName: "folder.badge.plus")
|
|
.font(.caption)
|
|
.foregroundColor(.gray)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("New Folder")
|
|
|
|
Button(action: workspaceService.refreshFileTree) {
|
|
Image(systemName: "arrow.clockwise")
|
|
.font(.caption)
|
|
.foregroundColor(.gray)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Refresh")
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
// MARK: - File List
|
|
|
|
private var fileList: some View {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
|
// New item input
|
|
if isCreatingFile || isCreatingFolder {
|
|
newItemRow
|
|
}
|
|
|
|
ForEach(workspaceService.fileTree) { node in
|
|
fileNodeView(node)
|
|
}
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
}
|
|
|
|
// MARK: - Node View (recursive)
|
|
|
|
@ViewBuilder
|
|
private func fileNodeView(_ node: FileNode) -> some View {
|
|
fileNodeRow(node)
|
|
|
|
if node.isDirectory && node.isExpanded, let children = node.children {
|
|
ForEach(children) { child in
|
|
AnyView(fileNodeView(child))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func fileNodeRow(_ node: FileNode) -> some View {
|
|
let isSelected = viewModel.activeTab?.url == node.url
|
|
let indent = CGFloat(node.depth) * 16 + 8
|
|
|
|
return HStack(spacing: 4) {
|
|
if node.isDirectory {
|
|
Image(systemName: node.isExpanded ? "chevron.down" : "chevron.right")
|
|
.font(.system(size: 8, weight: .bold))
|
|
.foregroundColor(.gray)
|
|
.frame(width: 10)
|
|
} else {
|
|
Spacer().frame(width: 10)
|
|
}
|
|
|
|
Image(systemName: node.iconName)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(colorForName(node.iconColor))
|
|
.frame(width: 14)
|
|
|
|
if renamingNode?.id == node.id {
|
|
TextField("", text: $newItemName)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.textFieldStyle(.plain)
|
|
.onSubmit { commitRename(node) }
|
|
.onAppear { newItemName = node.name }
|
|
} else {
|
|
Text(node.name)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(isSelected ? .white : .primary.opacity(0.85))
|
|
.lineLimit(1)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if let gitStatus = node.gitStatus {
|
|
Text(gitStatus.rawValue)
|
|
.font(.system(size: 9, weight: .bold, design: .monospaced))
|
|
.foregroundColor(colorForName(gitStatus.color))
|
|
}
|
|
}
|
|
.padding(.leading, indent)
|
|
.padding(.trailing, 8)
|
|
.padding(.vertical, 3)
|
|
.background(isSelected ? VSC.listActive : Color.clear)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
if node.isDirectory {
|
|
workspaceService.toggleExpansion(node)
|
|
} else {
|
|
viewModel.openFile(at: node.url)
|
|
}
|
|
}
|
|
.contextMenu { nodeContextMenu(node) }
|
|
}
|
|
|
|
// MARK: - Context Menu
|
|
|
|
@ViewBuilder
|
|
private func nodeContextMenu(_ node: FileNode) -> some View {
|
|
if node.isDirectory {
|
|
Button("New File...") {
|
|
contextMenuNode = node
|
|
isCreatingFile = true
|
|
}
|
|
Button("New Folder...") {
|
|
contextMenuNode = node
|
|
isCreatingFolder = true
|
|
}
|
|
Divider()
|
|
}
|
|
|
|
Button("Rename...") {
|
|
renamingNode = node
|
|
newItemName = node.name
|
|
}
|
|
|
|
Button("Delete") {
|
|
_ = workspaceService.deleteItem(node)
|
|
workspaceService.refreshFileTree()
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Reveal in Finder") {
|
|
NSWorkspace.shared.selectFile(node.url.path, inFileViewerRootedAtPath: node.url.deletingLastPathComponent().path)
|
|
}
|
|
|
|
Button("Copy Path") {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(node.url.path, forType: .string)
|
|
}
|
|
|
|
if !node.isDirectory {
|
|
Divider()
|
|
Button("Open in New Tab") {
|
|
viewModel.openFile(at: node.url)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - New Item Row
|
|
|
|
private var newItemRow: some View {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: isCreatingFolder ? "folder.badge.plus" : "doc.badge.plus")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(isCreatingFolder ? .blue : .orange)
|
|
TextField(isCreatingFolder ? "New folder name..." : "New file name...", text: $newItemName)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.textFieldStyle(.plain)
|
|
.onSubmit { commitNewItem() }
|
|
|
|
Button(action: { isCreatingFile = false; isCreatingFolder = false; newItemName = "" }) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.caption)
|
|
.foregroundColor(.gray)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.horizontal, 28)
|
|
.padding(.vertical, 3)
|
|
.background(Color.accentColor.opacity(0.1))
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func commitNewItem() {
|
|
guard !newItemName.isEmpty else { return }
|
|
if isCreatingFolder {
|
|
if workspaceService.createFolder(name: newItemName, in: contextMenuNode) != nil {
|
|
viewModel.notify(.success("Created folder: \(newItemName)"))
|
|
} else {
|
|
viewModel.notify(.error("Failed to create folder: \(newItemName)"))
|
|
}
|
|
} else {
|
|
if let fileURL = workspaceService.createFile(name: newItemName, in: contextMenuNode) {
|
|
viewModel.notify(.success("Created file: \(newItemName)"))
|
|
workspaceService.refreshFileTree()
|
|
viewModel.openFile(at: fileURL)
|
|
} else {
|
|
viewModel.notify(.error("Failed to create file: \(newItemName)"))
|
|
}
|
|
}
|
|
isCreatingFile = false
|
|
isCreatingFolder = false
|
|
newItemName = ""
|
|
contextMenuNode = nil
|
|
workspaceService.refreshFileTree()
|
|
}
|
|
|
|
private func commitRename(_ node: FileNode) {
|
|
guard !newItemName.isEmpty, newItemName != node.name else {
|
|
renamingNode = nil
|
|
return
|
|
}
|
|
_ = workspaceService.renameItem(node, to: newItemName)
|
|
renamingNode = nil
|
|
newItemName = ""
|
|
workspaceService.refreshFileTree()
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private var loadingView: some View {
|
|
VStack {
|
|
Spacer()
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
Text("Loading...")
|
|
.font(.caption)
|
|
.foregroundColor(.gray)
|
|
Spacer()
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
private var emptyView: some View {
|
|
VStack(spacing: 8) {
|
|
Spacer()
|
|
Image(systemName: "folder.badge.questionmark")
|
|
.font(.largeTitle)
|
|
.foregroundColor(.gray.opacity(0.3))
|
|
Text("No workspace open")
|
|
.font(.caption)
|
|
.foregroundColor(.gray)
|
|
Button("Open Folder...") {
|
|
workspaceService.openWorkspaceFromDialog()
|
|
}
|
|
.controlSize(.small)
|
|
Spacer()
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
private func colorForName(_ name: String) -> Color {
|
|
switch name {
|
|
case "orange": return .orange
|
|
case "blue": return .blue
|
|
case "purple": return .purple
|
|
case "cyan": return .cyan
|
|
case "teal": return .teal
|
|
case "yellow": return .yellow
|
|
case "green": return .green
|
|
case "red": return .red
|
|
case "gray": return .gray
|
|
default: return .primary
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Git Sidebar View
|
|
|
|
struct GitSidebarView: View {
|
|
@ObservedObject var gitService: GitService
|
|
@ObservedObject var viewModel: EditorViewModel
|
|
@State private var commitMessage: String = ""
|
|
@State private var showingLog: Bool = false
|
|
@State private var logEntries: [GitLogEntry] = []
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
gitHeader
|
|
Divider()
|
|
|
|
if !gitService.isGitRepo {
|
|
noRepoView
|
|
} else {
|
|
gitContent
|
|
}
|
|
}
|
|
.background(viewModel.currentTheme.sidebarBackground)
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
private var gitHeader: some View {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
|
.foregroundColor(.orange)
|
|
.font(.caption)
|
|
Text("SOURCE CONTROL")
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundColor(.gray)
|
|
Spacer()
|
|
|
|
if gitService.isRefreshing {
|
|
ProgressView()
|
|
.scaleEffect(0.5)
|
|
.frame(width: 14, height: 14)
|
|
}
|
|
|
|
Button(action: { Task { await gitService.refresh() } }) {
|
|
Image(systemName: "arrow.clockwise")
|
|
.font(.caption)
|
|
.foregroundColor(.gray)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
// MARK: - Git Content
|
|
|
|
private var gitContent: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
// Branch info
|
|
branchSection
|
|
|
|
// Commit box
|
|
commitSection
|
|
|
|
// Changed files
|
|
if !gitService.status.files.isEmpty {
|
|
changesSection
|
|
}
|
|
|
|
// Log toggle
|
|
logSection
|
|
}
|
|
.padding(10)
|
|
}
|
|
}
|
|
|
|
private var branchSection: some View {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "arrow.triangle.branch")
|
|
.font(.caption)
|
|
.foregroundColor(.green)
|
|
Text(gitService.status.branch)
|
|
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
|
.foregroundColor(.white)
|
|
|
|
if gitService.status.ahead > 0 {
|
|
HStack(spacing: 1) {
|
|
Image(systemName: "arrow.up")
|
|
.font(.system(size: 8))
|
|
Text("\(gitService.status.ahead)")
|
|
.font(.system(size: 9))
|
|
}
|
|
.foregroundColor(.green)
|
|
}
|
|
if gitService.status.behind > 0 {
|
|
HStack(spacing: 1) {
|
|
Image(systemName: "arrow.down")
|
|
.font(.system(size: 8))
|
|
Text("\(gitService.status.behind)")
|
|
.font(.system(size: 9))
|
|
}
|
|
.foregroundColor(.orange)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var commitSection: some View {
|
|
VStack(spacing: 6) {
|
|
TextField("Commit message...", text: $commitMessage, axis: .vertical)
|
|
.font(.system(size: 11))
|
|
.textFieldStyle(.roundedBorder)
|
|
.lineLimit(3)
|
|
|
|
HStack(spacing: 6) {
|
|
Button(action: {
|
|
Task {
|
|
await gitService.stageAll()
|
|
_ = await gitService.commit(message: commitMessage)
|
|
commitMessage = ""
|
|
}
|
|
}) {
|
|
HStack(spacing: 3) {
|
|
Image(systemName: "checkmark")
|
|
.font(.system(size: 9))
|
|
Text("Commit All")
|
|
.font(.system(size: 10, weight: .medium))
|
|
}
|
|
}
|
|
.controlSize(.small)
|
|
.disabled(commitMessage.isEmpty)
|
|
|
|
Button(action: { Task { await gitService.stageAll() } }) {
|
|
HStack(spacing: 3) {
|
|
Image(systemName: "plus")
|
|
.font(.system(size: 9))
|
|
Text("Stage All")
|
|
.font(.system(size: 10))
|
|
}
|
|
}
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var changesSection: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text("CHANGES (\(gitService.status.files.count))")
|
|
.font(.system(size: 9, weight: .bold))
|
|
.foregroundColor(.gray)
|
|
Spacer()
|
|
}
|
|
|
|
ForEach(gitService.status.files) { file in
|
|
HStack(spacing: 6) {
|
|
Text(file.status.rawValue)
|
|
.font(.system(size: 9, weight: .bold, design: .monospaced))
|
|
.foregroundColor(colorForStatus(file.status))
|
|
.frame(width: 14)
|
|
|
|
if file.staged {
|
|
Image(systemName: "circle.fill")
|
|
.font(.system(size: 4))
|
|
.foregroundColor(.green)
|
|
}
|
|
|
|
Text(file.path)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundColor(.white.opacity(0.85))
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 1)
|
|
.contextMenu {
|
|
Button("Stage") { Task { await gitService.stageFile(file.path) } }
|
|
Button("Unstage") { Task { await gitService.unstageFile(file.path) } }
|
|
Button("Discard Changes") { Task { await gitService.discardChanges(file.path) } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var logSection: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Button(action: {
|
|
showingLog.toggle()
|
|
if showingLog {
|
|
Task {
|
|
logEntries = await gitService.log(count: 10)
|
|
}
|
|
}
|
|
}) {
|
|
HStack {
|
|
Image(systemName: showingLog ? "chevron.down" : "chevron.right")
|
|
.font(.system(size: 8))
|
|
Text("HISTORY")
|
|
.font(.system(size: 9, weight: .bold))
|
|
}
|
|
.foregroundColor(.gray)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if showingLog {
|
|
ForEach(logEntries) { entry in
|
|
HStack(spacing: 6) {
|
|
Text(entry.hash)
|
|
.font(.system(size: 9, design: .monospaced))
|
|
.foregroundColor(.yellow.opacity(0.7))
|
|
Text(entry.message)
|
|
.font(.system(size: 10))
|
|
.foregroundColor(.white.opacity(0.8))
|
|
.lineLimit(1)
|
|
}
|
|
.padding(.vertical, 1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private var noRepoView: some View {
|
|
VStack(spacing: 8) {
|
|
Spacer()
|
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
|
.font(.largeTitle)
|
|
.foregroundColor(.gray.opacity(0.3))
|
|
Text("Not a Git repository")
|
|
.font(.caption)
|
|
.foregroundColor(.gray)
|
|
Spacer()
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
private func colorForStatus(_ status: GitFileStatus) -> Color {
|
|
switch status {
|
|
case .modified: return .orange
|
|
case .added: return .green
|
|
case .deleted: return .red
|
|
case .renamed: return .blue
|
|
case .untracked: return .gray
|
|
case .conflicted: return .red
|
|
}
|
|
}
|
|
}
|