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() // 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.. [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..= 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) } }