Files
CxIDE/Views/CodeEditorView.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

381 lines
13 KiB
Swift

import SwiftUI
// MARK: - Code Editor View (VSCode Style)
struct CodeEditorView: View {
@ObservedObject var viewModel: EditorViewModel
var body: some View {
VStack(spacing: 0) {
tabBar
breadcrumbs
editorArea
}
.background(viewModel.currentTheme.editorBackground)
.overlay(alignment: .topTrailing) {
if viewModel.searchState.isActive {
searchBar
.padding(.top, 70)
.padding(.trailing, 16)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
}
// MARK: - Tab Bar
private var tabBar: some View {
HStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
ForEach(viewModel.tabs) { tab in
tabItem(tab)
}
}
}
Spacer()
// Chat toggle button (right side of tab bar)
Button(action: { viewModel.showChatPanel.toggle() }) {
Image(systemName: viewModel.showChatPanel ? "message.fill" : "message")
.font(.system(size: 13))
.foregroundColor(viewModel.showChatPanel ? VSC.accentBlue : VSC.dimText)
.frame(width: 35, height: 35)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help("Toggle Chat (\u{2318}I)")
}
.frame(height: 35)
.background(VSC.tabBarBg)
}
private func tabItem(_ tab: EditorTab) -> some View {
let isActive = tab.id == viewModel.activeTabID
return HStack(spacing: 6) {
// File icon
Image(systemName: fileIcon(for: tab.language))
.font(.system(size: 11))
.foregroundColor(tab.language.iconColor)
// File name
Text(tab.name)
.font(.system(size: 12))
.foregroundColor(isActive ? VSC.text : VSC.dimText)
.lineLimit(1)
// Modified indicator
if tab.isModified {
Circle()
.fill(Color.white.opacity(0.6))
.frame(width: 6, height: 6)
}
// Close button
Button(action: { viewModel.closeTab(tab) }) {
Image(systemName: "xmark")
.font(.system(size: 8, weight: .bold))
.foregroundColor(VSC.dimText)
}
.buttonStyle(.plain)
.opacity(isActive ? 1 : 0.5)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(isActive ? viewModel.currentTheme.editorBackground : VSC.tabBarBg)
.overlay(alignment: .top) {
if isActive {
Rectangle()
.fill(VSC.accentBlue)
.frame(height: 1)
}
}
.overlay(alignment: .trailing) {
Rectangle()
.fill(VSC.border)
.frame(width: 1)
}
.contentShape(Rectangle())
.onTapGesture { viewModel.selectTab(tab) }
}
private func fileIcon(for lang: EditorTab.Language) -> String {
switch lang {
case .swift: return "swift"
case .objc: return "m.circle"
case .cpp, .c: return "c.circle"
case .header: return "h.circle"
case .json: return "curlybraces"
case .markdown: return "doc.richtext"
case .plaintext: return "doc.text"
}
}
// MARK: - Breadcrumbs
private var breadcrumbs: some View {
HStack(spacing: 4) {
if let tab = viewModel.activeTab, let url = tab.url {
let components = breadcrumbComponents(for: url)
ForEach(Array(components.enumerated()), id: \.offset) { idx, component in
if idx > 0 {
Image(systemName: "chevron.right")
.font(.system(size: 7))
.foregroundColor(VSC.dimText)
}
Text(component)
.font(.system(size: 11))
.foregroundColor(idx == components.count - 1 ? VSC.text : VSC.breadcrumbFg)
}
} else if let tab = viewModel.activeTab {
Text(tab.name)
.font(.system(size: 11))
.foregroundColor(VSC.text)
}
Spacer()
}
.padding(.horizontal, 12)
.frame(height: 22)
.background(viewModel.currentTheme.editorBackground)
.overlay(alignment: .bottom) {
Rectangle().fill(VSC.border).frame(height: 1)
}
}
private func breadcrumbComponents(for url: URL) -> [String] {
if let rootURL = viewModel.workspaceService.currentWorkspace?.rootURL {
let rootPath = rootURL.path
let filePath = url.path
if filePath.hasPrefix(rootPath) {
let relative = String(filePath.dropFirst(rootPath.count + 1))
return relative.components(separatedBy: "/")
}
}
return [url.lastPathComponent]
}
// MARK: - Editor Area
private var editorArea: some View {
HStack(spacing: 0) {
if viewModel.tabs.isEmpty {
emptyEditor
} else {
// Line numbers gutter
lineNumberGutter
// Code editor
codeEditor
// Minimap
minimapView
}
}
}
private var emptyEditor: some View {
VStack(spacing: 16) {
Spacer()
Image(systemName: "chevron.left.forwardslash.chevron.right")
.font(.system(size: 48))
.foregroundColor(VSC.dimText.opacity(0.3))
Text("Open a file to start editing")
.font(.system(size: 14))
.foregroundColor(VSC.dimText)
HStack(spacing: 16) {
Text("\u{2318}N New File")
Text("\u{2318}O Open File")
Text("\u{21e7}\u{2318}P Command Palette")
}
.font(.system(size: 11, design: .monospaced))
.foregroundColor(VSC.dimText.opacity(0.5))
Spacer()
}
.frame(maxWidth: .infinity)
.background(viewModel.currentTheme.editorBackground)
}
// MARK: - Line Number Gutter
private var lineNumberGutter: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .trailing, spacing: 0) {
ForEach(1...max(viewModel.lineCount, 1), id: \.self) { lineNum in
HStack(spacing: 0) {
// Breakpoint area
ZStack {
if viewModel.hasBreakpoint(at: lineNum) {
Circle()
.fill(Color.red)
.frame(width: 10, height: 10)
}
}
.frame(width: 16)
// Line number
Text("\(lineNum)")
.font(.system(size: viewModel.fontSize - 1, design: .monospaced))
.foregroundColor(viewModel.currentTheme.gutterForeground)
.frame(minWidth: 30, alignment: .trailing)
}
.frame(height: viewModel.fontSize * 1.6)
.contentShape(Rectangle())
.onTapGesture { viewModel.toggleBreakpoint(at: lineNum) }
}
}
.padding(.vertical, 4)
}
.frame(width: 60)
.background(viewModel.currentTheme.gutterBackground)
}
// MARK: - Code Editor
private var codeEditor: some View {
TextEditor(text: $viewModel.content)
.font(.system(size: viewModel.fontSize, design: .monospaced))
.foregroundColor(viewModel.currentTheme.editorForeground)
.scrollContentBackground(.hidden)
.background(viewModel.currentTheme.editorBackground)
.padding(.leading, 4)
.onChange(of: viewModel.content) { _, newValue in
if viewModel.searchState.isActive {
viewModel.performSearch()
}
}
}
// MARK: - Minimap
private var minimapView: some View {
let lines = viewModel.content.components(separatedBy: "\n")
let maxLines = min(lines.count, 300)
return ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
ForEach(0..<maxLines, id: \.self) { idx in
let line = lines[idx]
let trimmed = line.trimmingCharacters(in: .whitespaces)
let indent = CGFloat(line.count - trimmed.count)
Rectangle()
.fill(minimapColor(for: trimmed))
.frame(width: max(CGFloat(trimmed.count) * 0.5, 2), height: 2)
.padding(.leading, indent * 0.5)
}
}
.padding(.vertical, 4)
}
.frame(width: 60)
.background(viewModel.currentTheme.editorBackground.opacity(0.8))
.overlay(alignment: .leading) {
Rectangle().fill(VSC.border).frame(width: 1)
}
}
private func minimapColor(for line: String) -> Color {
if line.isEmpty { return .clear }
if line.hasPrefix("//") || line.hasPrefix("/*") { return viewModel.currentTheme.commentColor.opacity(0.5) }
if line.hasPrefix("import") || line.hasPrefix("func") || line.hasPrefix("class") ||
line.hasPrefix("struct") || line.hasPrefix("enum") || line.hasPrefix("var") ||
line.hasPrefix("let") || line.hasPrefix("if") || line.hasPrefix("for") ||
line.hasPrefix("while") || line.hasPrefix("return") || line.hasPrefix("guard") {
return viewModel.currentTheme.keywordColor.opacity(0.5)
}
if line.contains("\"") { return viewModel.currentTheme.stringColor.opacity(0.5) }
return viewModel.currentTheme.editorForeground.opacity(0.3)
}
// MARK: - Search Bar
private var searchBar: some View {
VStack(spacing: 6) {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.font(.system(size: 11))
.foregroundColor(VSC.dimText)
TextField("Find", text: $viewModel.searchState.query)
.textFieldStyle(.plain)
.font(.system(size: 12))
.foregroundColor(.white)
.onSubmit { viewModel.performSearch() }
.frame(width: 200)
if viewModel.searchState.matchCount > 0 {
Text("\(viewModel.searchState.currentMatchIndex + 1) of \(viewModel.searchState.matchCount)")
.font(.system(size: 10))
.foregroundColor(VSC.dimText)
}
Spacer()
Button(action: { viewModel.toggleSearch() }) {
Image(systemName: "xmark")
.font(.system(size: 10))
.foregroundColor(VSC.dimText)
}
.buttonStyle(.plain)
}
HStack(spacing: 6) {
Image(systemName: "arrow.2.squarepath")
.font(.system(size: 11))
.foregroundColor(VSC.dimText)
TextField("Replace", text: $viewModel.searchState.replacement)
.textFieldStyle(.plain)
.font(.system(size: 12))
.foregroundColor(.white)
.frame(width: 200)
Button(action: { viewModel.replaceNext() }) {
Image(systemName: "arrow.right")
.font(.system(size: 10))
}
.buttonStyle(.plain)
.help("Replace Next")
Button(action: { viewModel.replaceAll() }) {
Image(systemName: "arrow.right.arrow.right")
.font(.system(size: 10))
}
.buttonStyle(.plain)
.help("Replace All")
Spacer()
}
}
.padding(10)
.background(VSC.sidebarBg)
.cornerRadius(4)
.overlay(RoundedRectangle(cornerRadius: 4).stroke(VSC.border, lineWidth: 1))
.shadow(color: .black.opacity(0.3), radius: 8, y: 4)
}
}
// MARK: - Editor Minimap (Standalone)
struct EditorMinimapView: View {
let content: String
let theme: IDETheme
var body: some View {
let lines = content.components(separatedBy: "\n")
let maxLines = min(lines.count, 200)
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
ForEach(0..<maxLines, id: \.self) { idx in
Rectangle()
.fill(Color.white.opacity(0.2))
.frame(width: max(CGFloat(lines[idx].count) * 0.4, 1), height: 2)
}
}
}
.frame(width: 50)
.background(theme.editorBackground.opacity(0.8))
}
}