Files
CxIDE/Views/FileTreeView.swift
T
cx-git-agent c118996746 feat: CxIDE v1 — native macOS SwiftUI IDE with agentic AI assistant
- 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
2026-04-21 16:05:52 -05:00

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
}
}
}