Files
CxIDE/ViewModels/EditorViewModel.swift
T

851 lines
31 KiB
Swift

import SwiftUI
import Combine
@MainActor
class EditorViewModel: ObservableObject {
// MARK: - Editor State
@Published var content: String = EditorViewModel.defaultContent
@Published var output: String = ""
@Published var cppAnalysis: String = "Ready"
@Published var isRunning: Bool = false
@Published var consoleEntries: [ConsoleEntry] = [
ConsoleEntry(timestamp: Date(), message: "CxIDE initialized — ready to code.", level: .system)
]
@Published var fontSize: CGFloat = 13
@Published var showSidebar: Bool = true
@Published var selectedFile: String = "main.swift"
// MARK: - Multi-Tab State
@Published var tabs: [EditorTab] = []
@Published var activeTabID: UUID?
// MARK: - Search & Replace
@Published var searchState = SearchState()
@Published var workspaceSearchResults: [WorkspaceSearchResult] = []
@Published var isSearchingWorkspace: Bool = false
// MARK: - Theme
@Published var currentTheme: IDETheme = .vscodeDark
// MARK: - Activity Panel
@Published var activePanel: ActivityPanel = .explorer
@Published var showPanel: Bool = true
// MARK: - Diagnostics
@Published var diagnostics: [Diagnostic] = []
// MARK: - Breakpoints
@Published var breakpoints: [Breakpoint] = []
// MARK: - Execution History
@Published var executionHistory: [ExecutionRecord] = []
// MARK: - Bottom Panel Tab
@Published var bottomPanel: BottomPanel = .console
// MARK: - Shared Terminal
@Published var terminalSession = TerminalSession()
// MARK: - Command Palette
@Published var showCommandPalette: Bool = false
@Published var commandSearch: String = ""
// MARK: - Chat Panel (Right Side)
@Published var showChatPanel: Bool = false
// MARK: - Build Config
@Published var buildConfig: BuildConfiguration = .default
// MARK: - Agent
@Published var agentService = AgentService()
// MARK: - Workspace
@Published var workspaceService = WorkspaceService()
@Published var gitService = GitService()
@Published var notifications: [IDENotification] = []
// MARK: - Child Service Change Forwarding
private var serviceCancellables = Set<AnyCancellable>()
// MARK: - Split Editor
@Published var splitEditorEnabled: Bool = false
@Published var splitOrientation: SplitOrientation = .horizontal
var splitViewModel: EditorViewModel?
// MARK: - Document Tracking
@Published var documents: [Document] = []
private var autoSaveTimer: AnyCancellable?
enum BottomPanel: String, CaseIterable {
case console = "Console"
case problems = "Problems"
case terminal = "Terminal"
case output = "Output"
case debug = "Debug"
case agent = "Agent"
var iconName: String {
switch self {
case .console: return "terminal"
case .problems: return "exclamationmark.triangle"
case .terminal: return "terminal.fill"
case .output: return "doc.text"
case .debug: return "ant"
case .agent: return "cpu"
}
}
}
private let wrapper = SwiftEngineWrapper()
private var isSplitInstance = false
init() {
let initial = EditorTab(name: "main.swift", content: Self.defaultContent, language: .swift)
tabs = [initial]
activeTabID = initial.id
// Initialize agent with workspace path
let workspacePath = workspaceService.currentWorkspace?.rootURL.path
?? NSHomeDirectory()
agentService.initialize(workspacePath: workspacePath)
// When the agent creates a file, open it in the editor
agentService.onFileCreated = { [weak self] relativePath in
guard let self else { return }
let fullPath: String
if relativePath.hasPrefix("/") {
fullPath = relativePath
} else {
let ws = self.workspaceService.currentWorkspace?.rootURL.path ?? workspacePath
fullPath = (ws as NSString).appendingPathComponent(relativePath)
}
let url = URL(fileURLWithPath: fullPath)
self.openFile(at: url)
self.workspaceService.refreshFileTree()
}
// When the agent runs terminal commands, log them to console AND terminal
agentService.onTerminalCommand = { [weak self] command, output in
guard let self else { return }
self.appendConsole("$ \(command)", level: .system)
if !output.isEmpty {
let truncated = output.count > 500 ? String(output.prefix(500)) + "..." : output
self.appendConsole(truncated, level: .output)
}
// Also show in the shared terminal view
self.terminalSession.appendAgentCommand(command, output: output)
}
// Forward objectWillChange from child services so SwiftUI updates
agentService.objectWillChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &serviceCancellables)
workspaceService.objectWillChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &serviceCancellables)
terminalSession.objectWillChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &serviceCancellables)
gitService.objectWillChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
// Push git status to file tree nodes
if let self = self {
self.workspaceService.applyGitStatus(self.gitService.status.files)
}
}
.store(in: &serviceCancellables)
// Setup auto-save
setupAutoSave()
// Restore last workspace on launch
restoreLastWorkspace()
}
private func restoreLastWorkspace() {
guard !isSplitInstance else { return }
let state = workspaceService.state
if let lastID = state.lastActiveWorkspaceID,
let recent = state.recentWorkspaces.first(where: { $0.id == lastID }),
recent.exists {
openWorkspace(at: recent.url)
}
}
/// Creates a lightweight instance for split editor (shares no services)
init(asSplitEditor tab: EditorTab) {
self.isSplitInstance = true
self.tabs = [tab]
self.activeTabID = tab.id
self.content = tab.content
self.selectedFile = tab.name
}
// MARK: - Workspace Integration
func openWorkspace(at url: URL) {
workspaceService.openWorkspace(at: url)
gitService.configure(workingDirectory: url)
agentService.initialize(workspacePath: url.path)
appendConsole("Opened workspace: \(url.lastPathComponent)", level: .system)
notify(.success("Workspace opened: \(url.lastPathComponent)"))
}
func openWorkspaceFromDialog() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.message = "Choose a folder to open as workspace"
panel.prompt = "Open"
if panel.runModal() == .OK, let url = panel.url {
openWorkspace(at: url)
}
}
var hasWorkspace: Bool {
workspaceService.currentWorkspace != nil
}
// MARK: - Notifications
func notify(_ notification: IDENotification) {
notifications.append(notification)
// Auto-dismiss after 4 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in
self?.notifications.removeAll { $0.id == notification.id }
}
}
func dismissNotification(_ notification: IDENotification) {
notifications.removeAll { $0.id == notification.id }
}
// MARK: - Split Editor
func toggleSplitEditor() {
if splitEditorEnabled {
splitEditorEnabled = false
splitViewModel = nil
} else if let tab = activeTab {
let splitTab = EditorTab(name: tab.name, url: tab.url, content: tab.content, language: tab.language)
splitViewModel = EditorViewModel(asSplitEditor: splitTab)
splitEditorEnabled = true
}
}
// MARK: - Auto-Save
private func setupAutoSave() {
autoSaveTimer = $content
.debounce(for: .seconds(2), scheduler: RunLoop.main)
.sink { [weak self] _ in
guard let self = self else { return }
if let workspace = self.workspaceService.currentWorkspace,
workspace.settings.autoSave {
self.autoSaveCurrentFile()
}
}
}
private func autoSaveCurrentFile() {
saveCurrentTabContent()
// Re-fetch tab after save so we get updated content
guard let tab = activeTab, let url = tab.url else { return }
do {
try tab.content.write(to: url, atomically: true, encoding: .utf8)
if let idx = activeTabIndex { tabs[idx].isModified = false }
} catch {
// Silent fail for auto-save
}
}
// MARK: - Active Tab
var activeTab: EditorTab? {
tabs.first { $0.id == activeTabID }
}
private var activeTabIndex: Int? {
tabs.firstIndex { $0.id == activeTabID }
}
func selectTab(_ tab: EditorTab) {
saveCurrentTabContent()
activeTabID = tab.id
content = tab.content
selectedFile = tab.name
}
func openNewTab(name: String = "Untitled.swift", content: String = "", language: EditorTab.Language = .swift) {
saveCurrentTabContent()
let tab = EditorTab(name: name, content: content, language: language)
tabs.append(tab)
activeTabID = tab.id
self.content = content
selectedFile = name
appendConsole("Opened: \(name)", level: .system)
}
func closeTab(_ tab: EditorTab) {
guard let idx = tabs.firstIndex(of: tab) else { return }
tabs.remove(at: idx)
if tab.id == activeTabID {
if let next = tabs.first {
selectTab(next)
} else {
openNewTab()
}
}
}
func closeAllTabs() {
tabs.removeAll()
openNewTab()
}
private func saveCurrentTabContent() {
guard let idx = activeTabIndex else { return }
tabs[idx].content = content
tabs[idx].isModified = (tabs[idx].content != Self.defaultContent)
}
// MARK: - File Operations
func openFile(at url: URL) {
guard let data = try? Data(contentsOf: url),
let text = String(data: data, encoding: .utf8) else {
appendConsole("Failed to open: \(url.lastPathComponent)", level: .error)
return
}
let ext = url.pathExtension
let lang = EditorTab.Language.from(extension: ext)
if let existing = tabs.first(where: { $0.url == url }) {
selectTab(existing)
} else {
saveCurrentTabContent()
let tab = EditorTab(name: url.lastPathComponent, url: url, content: text, language: lang)
tabs.append(tab)
activeTabID = tab.id
content = text
selectedFile = url.lastPathComponent
}
appendConsole("Opened: \(url.lastPathComponent)", level: .system)
}
func saveCurrentFile() {
saveCurrentTabContent()
// Re-fetch tab after save so we get updated content
guard let tab = activeTab, let url = tab.url else {
appendConsole("No file URL to save to.", level: .error)
return
}
do {
try tab.content.write(to: url, atomically: true, encoding: .utf8)
if let idx = activeTabIndex { tabs[idx].isModified = false }
appendConsole("Saved: \(tab.name)", level: .system)
} catch {
appendConsole("Save failed: \(error.localizedDescription)", level: .error)
}
}
// MARK: - C++ Analysis
func runCppAnalysis() {
let result = wrapper.runAnalysis(content)
cppAnalysis = result
appendConsole("C++ syntax analysis complete.", level: .system)
diagnostics.removeAll()
let issues = wrapper.findIssues(content)
for issue in issues {
diagnostics.append(Diagnostic(line: 0, column: 0, message: issue, severity: .warning))
}
if !wrapper.checkBalancedBraces(content) {
diagnostics.append(Diagnostic(line: 0, column: 0, message: "Unbalanced braces detected", severity: .error))
}
}
func runChecksum() {
let sum = wrapper.getChecksum(content)
cppAnalysis = "Checksum: \(sum)"
}
func runComplexityCheck() {
let complexity = wrapper.estimateComplexity(content)
cppAnalysis = "Complexity: \(complexity)"
appendConsole("Complexity: \(complexity)", level: .debug)
}
func runBraceCheck() {
let balanced = wrapper.checkBalancedBraces(content)
cppAnalysis = balanced ? "✓ Braces are balanced" : "⚠ Unbalanced braces detected"
}
// MARK: - Code Execution
func runProject() async -> String {
guard !isRunning else { return "" }
isRunning = true
output = ""
diagnostics.removeAll()
appendConsole("▶ Starting execution...", level: .system)
let codeSnapshot = content
let tempDir = FileManager.default.temporaryDirectory
let fileURL = tempDir.appendingPathComponent("cxide_\(UUID().uuidString).swift")
// Run process off main actor to prevent UI freeze
let result: (stdout: String, stderr: String, exitCode: Int32, duration: Double) = await Task.detached {
let startTime = CFAbsoluteTimeGetCurrent()
do {
try codeSnapshot.write(to: fileURL, atomically: true, encoding: .utf8)
defer { try? FileManager.default.removeItem(at: fileURL) }
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/swift")
process.arguments = [fileURL.path]
process.currentDirectoryURL = tempDir
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
try process.run()
// Read pipes BEFORE waitUntilExit to avoid pipe buffer deadlock
let outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let errData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
let duration = CFAbsoluteTimeGetCurrent() - startTime
let stdout = String(data: outData, encoding: .utf8) ?? ""
let stderr = String(data: errData, encoding: .utf8) ?? ""
return (stdout, stderr, process.terminationStatus, duration)
} catch {
let duration = CFAbsoluteTimeGetCurrent() - startTime
return ("", "Execution failed: \(error.localizedDescription)", -1, duration)
}
}.value
// Back on @MainActor update UI state
output = result.stdout
if !result.stderr.isEmpty {
output += "\n" + result.stderr
parseDiagnostics(from: result.stderr)
appendConsole(result.stderr, level: .error)
}
if result.exitCode >= 0 {
let exitMsg = "Process exited with code \(result.exitCode) (\(String(format: "%.2fs", result.duration)))"
appendConsole(exitMsg, level: result.exitCode == 0 ? .output : .error)
output += "\n\(exitMsg)"
}
executionHistory.insert(ExecutionRecord(
timestamp: Date(), source: codeSnapshot, output: output,
exitCode: result.exitCode, duration: result.duration
), at: 0)
if executionHistory.count > 50 {
executionHistory = Array(executionHistory.prefix(50))
}
isRunning = false
return output
}
func runCode() {
Task { _ = await runProject() }
}
// MARK: - Search & Replace
func toggleSearch() {
searchState.isActive.toggle()
if !searchState.isActive {
searchState.query = ""
searchState.replacement = ""
searchState.matchCount = 0
}
}
func performSearch() {
guard !searchState.query.isEmpty else {
searchState.matchCount = 0
return
}
if searchState.isRegex {
// Regex search
var regexOptions: NSRegularExpression.Options = []
if !searchState.isCaseSensitive { regexOptions.insert(.caseInsensitive) }
guard let regex = try? NSRegularExpression(pattern: searchState.query, options: regexOptions) else {
searchState.matchCount = 0
return
}
let nsRange = NSRange(content.startIndex..., in: content)
searchState.matchCount = regex.numberOfMatches(in: content, range: nsRange)
} else {
// Plain text search
var query = searchState.query
if searchState.isWholeWord {
query = "\\b\(NSRegularExpression.escapedPattern(for: query))\\b"
var regexOptions: NSRegularExpression.Options = []
if !searchState.isCaseSensitive { regexOptions.insert(.caseInsensitive) }
guard let regex = try? NSRegularExpression(pattern: query, options: regexOptions) else {
searchState.matchCount = 0
return
}
let nsRange = NSRange(content.startIndex..., in: content)
searchState.matchCount = regex.numberOfMatches(in: content, range: nsRange)
} else {
let options: String.CompareOptions = searchState.isCaseSensitive ? [] : [.caseInsensitive]
var count = 0
var searchRange = content.startIndex..<content.endIndex
while let range = content.range(of: searchState.query, options: options, range: searchRange) {
count += 1
searchRange = range.upperBound..<content.endIndex
}
searchState.matchCount = count
}
}
}
func replaceNext() {
guard !searchState.query.isEmpty, searchState.hasMatches else { return }
let options: String.CompareOptions = searchState.isCaseSensitive ? [] : [.caseInsensitive]
if let range = content.range(of: searchState.query, options: options) {
content.replaceSubrange(range, with: searchState.replacement)
performSearch()
}
}
func replaceAll() {
guard !searchState.query.isEmpty else { return }
if searchState.isCaseSensitive {
content = content.replacingOccurrences(of: searchState.query, with: searchState.replacement)
} else {
content = content.replacingOccurrences(
of: searchState.query, with: searchState.replacement,
options: [.caseInsensitive]
)
}
performSearch()
appendConsole("Replaced all occurrences.", level: .system)
}
func searchWorkspace() {
guard !searchState.query.isEmpty,
let rootURL = workspaceService.currentWorkspace?.rootURL else { return }
isSearchingWorkspace = true
workspaceSearchResults = []
let query = searchState.query
let caseSensitive = searchState.isCaseSensitive
let excludes = workspaceService.currentWorkspace?.settings.excludePatterns ?? []
Task.detached(priority: .userInitiated) {
let results = Self.searchFiles(
in: rootURL, query: query,
caseSensitive: caseSensitive, excludePatterns: excludes
)
await MainActor.run { [weak self] in
self?.workspaceSearchResults = results
self?.searchState.matchCount = results.count
self?.isSearchingWorkspace = false
}
}
}
private nonisolated static func searchFiles(
in directory: URL, query: String,
caseSensitive: Bool, excludePatterns: [String]
) -> [WorkspaceSearchResult] {
var results: [WorkspaceSearchResult] = []
let fm = FileManager.default
let searchOptions: String.CompareOptions = caseSensitive ? [] : [.caseInsensitive]
let sourceExtensions = Set(["swift", "c", "cpp", "cc", "h", "hpp", "m", "mm",
"json", "md", "txt", "yaml", "yml", "py", "js",
"ts", "sh", "bash", "toml", "plist", "xml", "css", "html"])
guard let enumerator = fm.enumerator(
at: directory,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
) else { return [] }
for case let fileURL as URL in enumerator {
let name = fileURL.lastPathComponent
if excludePatterns.contains(where: { name == $0 || name.hasSuffix($0) }) {
enumerator.skipDescendants()
continue
}
guard sourceExtensions.contains(fileURL.pathExtension.lowercased()) else { continue }
guard let data = try? Data(contentsOf: fileURL),
let content = String(data: data, encoding: .utf8) else { continue }
let lines = content.components(separatedBy: "\n")
for (lineIdx, line) in lines.enumerated() {
var searchRange = line.startIndex..<line.endIndex
while let range = line.range(of: query, options: searchOptions, range: searchRange) {
let col = line.distance(from: line.startIndex, to: range.lowerBound) + 1
results.append(WorkspaceSearchResult(
fileURL: fileURL,
fileName: fileURL.lastPathComponent,
line: lineIdx + 1,
column: col,
lineContent: String(line.prefix(200)),
matchRange: range
))
searchRange = range.upperBound..<line.endIndex
if results.count >= 5000 { return results }
}
}
}
return results
}
// MARK: - Breakpoints
func toggleBreakpoint(at line: Int) {
if let idx = breakpoints.firstIndex(where: { $0.file == selectedFile && $0.line == line }) {
breakpoints.remove(at: idx)
} else {
breakpoints.append(Breakpoint(file: selectedFile, line: line))
}
}
func hasBreakpoint(at line: Int) -> Bool {
breakpoints.contains { $0.file == selectedFile && $0.line == line && $0.isEnabled }
}
func clearAllBreakpoints() {
breakpoints.removeAll()
}
// MARK: - Snippets
func insertSnippet(_ snippet: CodeSnippet) {
content += "\n\(snippet.code)\n"
appendConsole("Inserted snippet: \(snippet.title)", level: .debug)
}
// MARK: - Theme
func cycleTheme() {
let themes = IDETheme.allThemes
guard let idx = themes.firstIndex(of: currentTheme) else {
currentTheme = .midnight
return
}
currentTheme = themes[(idx + 1) % themes.count]
appendConsole("Theme: \(currentTheme.name)", level: .system)
}
// MARK: - Command Palette
var commands: [CommandItem] {
[
// File
CommandItem(title: "Run Code", shortcut: "⌘↩", icon: "play.fill", action: runCode),
CommandItem(title: "New Tab", shortcut: "⌘N", icon: "plus", action: { [weak self] in self?.openNewTab() }),
CommandItem(title: "Save File", shortcut: "⌘S", icon: "square.and.arrow.down", action: saveCurrentFile),
CommandItem(title: "Close Tab", shortcut: "⌘W", icon: "xmark", action: { [weak self] in
guard let tab = self?.activeTab else { return }
self?.closeTab(tab)
}),
CommandItem(title: "Close All Tabs", icon: "xmark.circle", action: closeAllTabs),
// Workspace
CommandItem(title: "Open Folder...", shortcut: "⌘O", icon: "folder", action: { [weak self] in self?.openWorkspaceFromDialog() }),
CommandItem(title: "Refresh File Tree", icon: "arrow.clockwise", action: { [weak self] in self?.workspaceService.refreshFileTree() }),
// View
CommandItem(title: "Toggle Sidebar", shortcut: "⌘B", icon: "sidebar.left", action: { [weak self] in self?.showSidebar.toggle() }),
CommandItem(title: "Toggle Search", shortcut: "⌘F", icon: "magnifyingglass", action: toggleSearch),
CommandItem(title: "Toggle Split Editor", icon: "rectangle.split.2x1", action: { [weak self] in self?.toggleSplitEditor() }),
CommandItem(title: "Show Terminal", icon: "terminal.fill", action: { [weak self] in self?.bottomPanel = .terminal }),
CommandItem(title: "Show Problems", icon: "exclamationmark.triangle", action: { [weak self] in self?.bottomPanel = .problems }),
CommandItem(title: "Cycle Theme", shortcut: "⌘K", icon: "paintpalette", action: cycleTheme),
// Editor
CommandItem(title: "Increase Font", shortcut: "⌘+", icon: "textformat.size.larger", action: increaseFontSize),
CommandItem(title: "Decrease Font", shortcut: "⌘-", icon: "textformat.size.smaller", action: decreaseFontSize),
// Analysis
CommandItem(title: "Run C++ Analysis", icon: "wand.and.stars", action: runCppAnalysis),
CommandItem(title: "Check Complexity", icon: "gauge.medium", action: runComplexityCheck),
CommandItem(title: "Verify Braces", icon: "curlybraces", action: runBraceCheck),
// Debug
CommandItem(title: "Clear Console", icon: "trash", action: clearConsole),
CommandItem(title: "Clear All Breakpoints", icon: "circle.slash", action: clearAllBreakpoints),
// Git
CommandItem(title: "Git: Refresh", icon: "arrow.triangle.branch", action: { [weak self] in
Task { await self?.gitService.refresh() }
}),
CommandItem(title: "Git: Stage All", icon: "plus.circle", action: { [weak self] in
Task { await self?.gitService.stageAll() }
}),
// Agent
CommandItem(title: "Agent: Explain Code", shortcut: "⌘⇧E", icon: "bubble.left.and.text.bubble.right", action: { [weak self] in self?.agentExplainCode() }),
CommandItem(title: "Agent: Review Code", shortcut: "⌘⇧R", icon: "eye", action: { [weak self] in self?.agentReviewCode() }),
CommandItem(title: "Agent: Fix Issues", icon: "wrench.and.screwdriver", action: { [weak self] in self?.agentFixCode() }),
CommandItem(title: "Agent: Generate Tests", icon: "checkmark.seal", action: { [weak self] in self?.agentGenerateTests() }),
CommandItem(title: "Agent: Analyze Workspace", icon: "folder.badge.gearshape", action: { [weak self] in self?.agentAnalyzeWorkspace() }),
CommandItem(title: "Agent: Run Diagnostics", icon: "stethoscope", action: { [weak self] in self?.agentRunDiagnostics() }),
CommandItem(title: "Agent: Security Audit", icon: "lock.shield", action: { [weak self] in self?.agentSecurityAudit() }),
CommandItem(title: "Agent: List Tools", icon: "list.bullet", action: { [weak self] in self?.agentListTools() }),
]
}
var filteredCommands: [CommandItem] {
if commandSearch.isEmpty { return commands }
return commands.filter { $0.title.localizedCaseInsensitiveContains(commandSearch) }
}
// MARK: - Compatibility Aliases
var isProcessing: Bool { isRunning }
var text: String {
get { content }
set { content = newValue }
}
// MARK: - Console
func appendConsole(_ message: String, level: ConsoleEntry.Level) {
consoleEntries.append(ConsoleEntry(timestamp: Date(), message: message, level: level))
if consoleEntries.count > 1000 {
consoleEntries = Array(consoleEntries.suffix(1000))
}
}
func clearConsole() {
consoleEntries.removeAll()
output = ""
appendConsole("Console cleared.", level: .system)
}
// MARK: - Editor Helpers
var lineCount: Int {
content.components(separatedBy: .newlines).count
}
var characterCount: Int {
content.count
}
var wordCount: Int {
content.split { $0.isWhitespace || $0.isNewline }.count
}
func increaseFontSize() {
fontSize = min(fontSize + 1, 32)
}
func decreaseFontSize() {
fontSize = max(fontSize - 1, 9)
}
// MARK: - Diagnostics
var errorCount: Int { diagnostics.filter { $0.severity == .error }.count }
var warningCount: Int { diagnostics.filter { $0.severity == .warning }.count }
private func parseDiagnostics(from stderr: String) {
let lines = stderr.components(separatedBy: .newlines)
for errLine in lines where !errLine.isEmpty {
if errLine.contains("error:") {
diagnostics.append(Diagnostic(line: 0, column: 0, message: errLine, severity: .error))
} else if errLine.contains("warning:") {
diagnostics.append(Diagnostic(line: 0, column: 0, message: errLine, severity: .warning))
}
}
}
// MARK: - Default Content
private static let defaultContent = """
// CxIDE — Swift Code Editor
// Press ⌘+Return to run • ⌘+Shift+P for command palette
import Foundation
let message = "Hello from CxIDE!"
print(message)
for i in 1...5 {
print("Item \\(i)")
}
"""
// MARK: - Agent Actions
func agentExplainCode() {
bottomPanel = .agent
Task { await agentService.explainCode(content) }
}
func agentReviewCode() {
bottomPanel = .agent
Task { await agentService.reviewCode(content) }
}
func agentFixCode() {
bottomPanel = .agent
Task { await agentService.fixCode(content) }
}
func agentGenerateTests() {
bottomPanel = .agent
Task { await agentService.generateTests(content) }
}
func agentAnalyzeWorkspace() {
bottomPanel = .agent
Task { await agentService.analyzeWorkspace() }
}
func agentRunDiagnostics() {
bottomPanel = .agent
Task { await agentService.runDiagnostics() }
}
func agentSecurityAudit() {
bottomPanel = .agent
Task { await agentService.securityAudit(content) }
}
func agentListTools() {
bottomPanel = .agent
Task { await agentService.sendMessage("list tools") }
}
func sendAgentMessage(_ text: String) {
Task { await agentService.sendMessage(text) }
}
func sendChatMessage(_ text: String) {
Task { await agentService.sendChatMessage(text) }
}
func applyAgentSuggestion(_ code: String) {
content = code
saveCurrentTabContent()
appendConsole("Applied agent suggestion.", level: .agent)
}
}