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
381 lines
13 KiB
Swift
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))
|
|
}
|
|
}
|