PadXcode-iPad/Editor/EditorState.swift

118 lines
3.9 KiB
Swift
Raw Normal View History

2026-04-12 00:46:30 -07:00
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 }
}
private var autosaveTask: Task<Void, Never>?
var onSaveRequest: ((String, String) async -> Void)?
// MARK: - Active Theme
var activeTheme: any Theme {
switch selectedTheme {
case .xcodeDefault: return PadXcodeTheme()
case .xcodeLight: return PadXcodeLightTheme()
}
}
// 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 }
tabs.remove(at: idx)
if activeTabId == id {
activeTabId = tabs.isEmpty ? nil : tabs[min(idx, tabs.count - 1)].id
}
}
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) {
autosaveTask?.cancel()
autosaveTask = 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
}
}
}
var dirtyTabNames: [String] { tabs.filter { $0.isDirty }.map { $0.fileName } }
}