117 lines
3.9 KiB
Swift
117 lines
3.9 KiB
Swift
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 } }
|
|
}
|