import SwiftUI import Runestone // provides Theme protocol // MARK: - Open Tab struct EditorTab: Identifiable, Equatable { let id = UUID() let path: String var content: String var isDirty: Bool = false var diagnostics: [DiagnosticRange] = [] var fileName: String { URL(fileURLWithPath: path).lastPathComponent } } // MARK: - Editor State @MainActor final class EditorState: ObservableObject { @Published var tabs: [EditorTab] = [] @Published var activeTabId: UUID? // @AppStorage supports: Bool, Int, Double, String, Data, URL // CGFloat → store as Double, expose computed CGFloat // EditorThemeOption → store as String (rawValue), decode manually @AppStorage("editorFontSize") var fontSizeStored: Double = 14 @AppStorage("themeRawValue") var themeRawValue: String = EditorThemeOption.xcodeDefault.rawValue @AppStorage("showLineNumbers") var showLineNumbers: Bool = true @AppStorage("showInvisibles") var showInvisibles: Bool = false @AppStorage("lineWrapping") var lineWrapping: Bool = true @AppStorage("indentWidth") var indentWidth: Int = 4 /// Cast to CGFloat wherever Runestone or SwiftUI font APIs need it. var fontSize: CGFloat { CGFloat(fontSizeStored) } /// Decoded from the stored rawValue string. var selectedTheme: EditorThemeOption { get { EditorThemeOption(rawValue: themeRawValue) ?? .xcodeDefault } set { themeRawValue = newValue.rawValue } } // Keyed by tab UUID — each tab has its own debounce so edits on one // tab don't cancel pending saves on another. private var autosaveTasks: [UUID: Task] = [:] var onSaveRequest: ((String, String) async -> Void)? var onTabClosed: ((String) -> Void)? // MARK: - Active Theme var activeTheme: any Theme { switch selectedTheme { case .xcodeDefault: return PadXcodeTheme(fontSize: fontSize) case .xcodeLight: return PadXcodeLightTheme(fontSize: fontSize) } } // MARK: - Tab Management var activeTab: EditorTab? { guard let id = activeTabId else { return nil } return tabs.first { $0.id == id } } func openFile(path: String, content: String) { if let existing = tabs.first(where: { $0.path == path }) { activeTabId = existing.id return } let tab = EditorTab(path: path, content: content) tabs.append(tab) activeTabId = tab.id } func closeTab(id: UUID) { guard let idx = tabs.firstIndex(where: { $0.id == id }) else { return } let closedPath = tabs[idx].path autosaveTasks[id]?.cancel() autosaveTasks.removeValue(forKey: id) tabs.remove(at: idx) if activeTabId == id { activeTabId = tabs.isEmpty ? nil : tabs[min(idx, tabs.count - 1)].id } onTabClosed?(closedPath) } func updateContent(tabId: UUID, newContent: String) { guard let idx = tabs.firstIndex(where: { $0.id == tabId }) else { return } tabs[idx].content = newContent tabs[idx].isDirty = true scheduleAutosave(tabId: tabId) } // MARK: - Diagnostics func applyDiagnostics(_ diagnostics: [DiagnosticRange], toPath path: String) { guard let idx = tabs.firstIndex(where: { $0.path == path }) else { return } tabs[idx].diagnostics = diagnostics } func clearDiagnostics(forPath path: String) { guard let idx = tabs.firstIndex(where: { $0.path == path }) else { return } tabs[idx].diagnostics = [] } // MARK: - Autosave private func scheduleAutosave(tabId: UUID) { autosaveTasks[tabId]?.cancel() autosaveTasks[tabId] = Task { [weak self] in try? await Task.sleep(for: .seconds(1.5)) guard !Task.isCancelled, let self, let tab = self.tabs.first(where: { $0.id == tabId }), tab.isDirty else { return } await self.onSaveRequest?(tab.path, tab.content) if let idx = self.tabs.firstIndex(where: { $0.id == tabId }) { self.tabs[idx].isDirty = false self.autosaveTasks.removeValue(forKey: tabId) } } } func markSaved(tabId: UUID) { guard let idx = tabs.firstIndex(where: { $0.id == tabId }) else { return } tabs[idx].isDirty = false } var dirtyTabNames: [String] { tabs.filter { $0.isDirty }.map { $0.fileName } } }