851 lines
31 KiB
Swift
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)
|
|
}
|
|
}
|