2025-09-25 09:00:22 +00:00
|
|
|
import SwiftUI
|
2025-09-25 09:01:45 +00:00
|
|
|
import Combine
|
2025-09-25 09:00:22 +00:00
|
|
|
import UniformTypeIdentifiers
|
2026-02-06 13:29:34 +00:00
|
|
|
import Foundation
|
2026-02-18 19:19:49 +00:00
|
|
|
import OSLog
|
2026-02-07 10:51:52 +00:00
|
|
|
#if canImport(UIKit)
|
|
|
|
|
import UIKit
|
|
|
|
|
#endif
|
2025-09-25 09:00:22 +00:00
|
|
|
|
2026-02-14 13:24:01 +00:00
|
|
|
///MARK: - Text Sanitization
|
|
|
|
|
// Normalizes pasted and loaded text before it reaches editor state.
|
2026-02-11 10:20:17 +00:00
|
|
|
enum EditorTextSanitizer {
|
2026-02-14 13:24:01 +00:00
|
|
|
// Converts control/marker glyphs into safe spaces/newlines and removes unsupported scalars.
|
2026-02-11 10:20:17 +00:00
|
|
|
static func sanitize(_ input: String) -> String {
|
2026-02-11 11:07:07 +00:00
|
|
|
// Normalize line endings first so CRLF does not become double newlines.
|
|
|
|
|
let normalized = input
|
|
|
|
|
.replacingOccurrences(of: "\r\n", with: "\n")
|
|
|
|
|
.replacingOccurrences(of: "\r", with: "\n")
|
2026-02-11 10:20:17 +00:00
|
|
|
var result = String.UnicodeScalarView()
|
2026-02-11 11:07:07 +00:00
|
|
|
result.reserveCapacity(normalized.unicodeScalars.count)
|
|
|
|
|
for scalar in normalized.unicodeScalars {
|
2026-02-11 10:20:17 +00:00
|
|
|
switch scalar {
|
|
|
|
|
case "\n":
|
|
|
|
|
result.append(scalar)
|
|
|
|
|
case "\t", "\u{000B}", "\u{000C}":
|
|
|
|
|
result.append(" ")
|
|
|
|
|
case "\u{00A0}":
|
|
|
|
|
result.append(" ")
|
|
|
|
|
case "\u{00B7}", "\u{2022}", "\u{2219}", "\u{237D}", "\u{2420}", "\u{2422}", "\u{2423}", "\u{2581}":
|
|
|
|
|
result.append(" ")
|
|
|
|
|
case "\u{00BB}", "\u{2192}", "\u{21E5}":
|
|
|
|
|
result.append(" ")
|
|
|
|
|
case "\u{00B6}", "\u{21A9}", "\u{21B2}", "\u{21B5}", "\u{23CE}", "\u{2424}", "\u{2425}":
|
|
|
|
|
result.append("\n")
|
|
|
|
|
case "\u{240A}", "\u{240D}":
|
|
|
|
|
result.append("\n")
|
|
|
|
|
default:
|
|
|
|
|
let cat = scalar.properties.generalCategory
|
|
|
|
|
if cat == .format || cat == .control || cat == .lineSeparator || cat == .paragraphSeparator {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if (0x2400...0x243F).contains(scalar.value) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if cat == .spaceSeparator && scalar != " " && scalar != "\t" {
|
|
|
|
|
result.append(" ")
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
result.append(scalar)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return String(result)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 13:24:01 +00:00
|
|
|
///MARK: - Tab Model
|
|
|
|
|
// Represents one editor tab and its mutable editing state.
|
2025-09-25 09:01:45 +00:00
|
|
|
struct TabData: Identifiable {
|
|
|
|
|
let id = UUID()
|
|
|
|
|
var name: String
|
|
|
|
|
var content: String
|
|
|
|
|
var language: String
|
|
|
|
|
var fileURL: URL?
|
2026-02-06 13:29:34 +00:00
|
|
|
var languageLocked: Bool = false
|
2026-02-06 19:20:03 +00:00
|
|
|
var isDirty: Bool = false
|
2026-02-18 19:19:49 +00:00
|
|
|
var lastSavedFingerprint: UInt64?
|
2025-09-25 09:01:45 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-14 13:24:01 +00:00
|
|
|
///MARK: - Editor View Model
|
|
|
|
|
// Owns tab lifecycle, file IO, and language-detection behavior.
|
2025-09-25 09:01:45 +00:00
|
|
|
@MainActor
|
2025-09-25 09:00:22 +00:00
|
|
|
class EditorViewModel: ObservableObject {
|
2026-02-18 19:19:49 +00:00
|
|
|
private static let saveSignposter = OSSignposter(subsystem: "h3p.Neon-Vision-Editor", category: "FileIO")
|
2025-09-25 09:01:45 +00:00
|
|
|
@Published var tabs: [TabData] = []
|
|
|
|
|
@Published var selectedTabID: UUID?
|
2025-09-25 09:00:22 +00:00
|
|
|
@Published var showSidebar: Bool = true
|
2025-09-25 09:01:45 +00:00
|
|
|
@Published var isBrainDumpMode: Bool = false
|
|
|
|
|
@Published var showingRename: Bool = false
|
|
|
|
|
@Published var renameText: String = ""
|
2026-01-17 11:11:26 +00:00
|
|
|
@Published var isLineWrapEnabled: Bool = true
|
2025-09-25 09:01:45 +00:00
|
|
|
|
|
|
|
|
var selectedTab: TabData? {
|
|
|
|
|
get { tabs.first(where: { $0.id == selectedTabID }) }
|
|
|
|
|
set { selectedTabID = newValue?.id }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private let languageMap: [String: String] = [
|
|
|
|
|
"swift": "swift",
|
|
|
|
|
"py": "python",
|
2026-02-08 01:03:34 +00:00
|
|
|
"pyi": "python",
|
2025-09-25 09:01:45 +00:00
|
|
|
"js": "javascript",
|
2026-02-08 01:03:34 +00:00
|
|
|
"mjs": "javascript",
|
|
|
|
|
"cjs": "javascript",
|
|
|
|
|
"ts": "typescript",
|
|
|
|
|
"tsx": "typescript",
|
2026-02-07 23:20:47 +00:00
|
|
|
"php": "php",
|
|
|
|
|
"phtml": "php",
|
|
|
|
|
"csv": "csv",
|
|
|
|
|
"tsv": "csv",
|
2026-02-08 01:03:34 +00:00
|
|
|
"toml": "toml",
|
|
|
|
|
"ini": "ini",
|
|
|
|
|
"yaml": "yaml",
|
|
|
|
|
"yml": "yaml",
|
|
|
|
|
"xml": "xml",
|
|
|
|
|
"sql": "sql",
|
2026-02-08 11:14:49 +00:00
|
|
|
"log": "log",
|
|
|
|
|
"vim": "vim",
|
|
|
|
|
"ipynb": "ipynb",
|
2026-02-08 01:03:34 +00:00
|
|
|
"java": "java",
|
|
|
|
|
"kt": "kotlin",
|
|
|
|
|
"kts": "kotlin",
|
|
|
|
|
"go": "go",
|
|
|
|
|
"rb": "ruby",
|
|
|
|
|
"rs": "rust",
|
|
|
|
|
"ps1": "powershell",
|
|
|
|
|
"psm1": "powershell",
|
2025-09-25 09:01:45 +00:00
|
|
|
"html": "html",
|
2026-02-08 01:03:34 +00:00
|
|
|
"htm": "html",
|
2026-02-13 11:02:39 +00:00
|
|
|
"ee": "expressionengine",
|
|
|
|
|
"exp": "expressionengine",
|
|
|
|
|
"tmpl": "expressionengine",
|
2025-09-25 09:01:45 +00:00
|
|
|
"css": "css",
|
|
|
|
|
"c": "c",
|
|
|
|
|
"cpp": "cpp",
|
2026-02-08 01:03:34 +00:00
|
|
|
"cc": "cpp",
|
|
|
|
|
"hpp": "cpp",
|
|
|
|
|
"hh": "cpp",
|
2026-02-08 11:14:49 +00:00
|
|
|
"h": "cpp",
|
2026-02-09 10:21:50 +00:00
|
|
|
"cs": "csharp",
|
2026-02-08 01:03:34 +00:00
|
|
|
"m": "objective-c",
|
|
|
|
|
"mm": "objective-c",
|
2025-09-25 09:01:45 +00:00
|
|
|
"json": "json",
|
2026-02-08 01:03:34 +00:00
|
|
|
"jsonc": "json",
|
|
|
|
|
"json5": "json",
|
2026-01-23 11:49:52 +00:00
|
|
|
"md": "markdown",
|
2026-02-08 01:03:34 +00:00
|
|
|
"markdown": "markdown",
|
2026-02-08 22:41:39 +00:00
|
|
|
"env": "dotenv",
|
|
|
|
|
"proto": "proto",
|
|
|
|
|
"graphql": "graphql",
|
|
|
|
|
"gql": "graphql",
|
|
|
|
|
"rst": "rst",
|
|
|
|
|
"conf": "nginx",
|
|
|
|
|
"nginx": "nginx",
|
|
|
|
|
"cob": "cobol",
|
|
|
|
|
"cbl": "cobol",
|
|
|
|
|
"cobol": "cobol",
|
2026-01-23 11:49:52 +00:00
|
|
|
"sh": "bash",
|
|
|
|
|
"bash": "bash",
|
|
|
|
|
"zsh": "zsh"
|
2025-09-25 09:01:45 +00:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
init() {
|
|
|
|
|
addNewTab()
|
|
|
|
|
}
|
2026-02-14 13:24:01 +00:00
|
|
|
|
|
|
|
|
// Creates and selects a new untitled tab.
|
2025-09-25 09:01:45 +00:00
|
|
|
func addNewTab() {
|
2026-02-11 10:20:17 +00:00
|
|
|
// Keep language discovery active for new untitled tabs.
|
2026-02-12 22:20:39 +00:00
|
|
|
let newTab = TabData(name: "Untitled \(tabs.count + 1)", content: "", language: defaultNewTabLanguage(), fileURL: nil, languageLocked: false)
|
2025-09-25 09:00:22 +00:00
|
|
|
tabs.append(newTab)
|
2025-09-25 09:01:45 +00:00
|
|
|
selectedTabID = newTab.id
|
2025-09-25 09:00:22 +00:00
|
|
|
}
|
2026-02-14 13:24:01 +00:00
|
|
|
|
|
|
|
|
// Renames an existing tab.
|
2025-09-25 09:01:45 +00:00
|
|
|
func renameTab(tab: TabData, newName: String) {
|
|
|
|
|
if let index = tabs.firstIndex(where: { $0.id == tab.id }) {
|
|
|
|
|
tabs[index].name = newName
|
2025-09-25 09:00:22 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-14 13:24:01 +00:00
|
|
|
|
|
|
|
|
// Updates tab text and applies language detection/locking heuristics.
|
2025-09-25 09:01:45 +00:00
|
|
|
func updateTabContent(tab: TabData, content: String) {
|
|
|
|
|
if let index = tabs.firstIndex(where: { $0.id == tab.id }) {
|
2026-02-06 19:20:03 +00:00
|
|
|
let previous = tabs[index].content
|
2025-09-25 09:01:45 +00:00
|
|
|
tabs[index].content = content
|
2026-02-06 19:20:03 +00:00
|
|
|
if content != previous {
|
|
|
|
|
tabs[index].isDirty = true
|
|
|
|
|
}
|
2026-02-08 11:57:41 +00:00
|
|
|
|
|
|
|
|
let isLargeContent = (content as NSString).length >= 1_000_000
|
|
|
|
|
if isLargeContent {
|
|
|
|
|
let nameExt = URL(fileURLWithPath: tabs[index].name).pathExtension.lowercased()
|
|
|
|
|
if !tabs[index].languageLocked,
|
|
|
|
|
let mapped = LanguageDetector.shared.preferredLanguage(for: tabs[index].fileURL) ??
|
|
|
|
|
languageMap[nameExt] {
|
|
|
|
|
tabs[index].language = mapped
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-06 13:29:34 +00:00
|
|
|
|
|
|
|
|
// Early lock to Swift if clearly Swift-specific tokens are present
|
|
|
|
|
let lower = content.lowercased()
|
|
|
|
|
let swiftStrongTokens: Bool = (
|
|
|
|
|
lower.contains(" import swiftui") ||
|
|
|
|
|
lower.hasPrefix("import swiftui") ||
|
|
|
|
|
lower.contains("@main") ||
|
|
|
|
|
lower.contains(" final class ") ||
|
|
|
|
|
lower.contains("public final class ") ||
|
|
|
|
|
lower.contains(": view") ||
|
|
|
|
|
lower.contains("@published") ||
|
|
|
|
|
lower.contains("@stateobject") ||
|
|
|
|
|
lower.contains("@mainactor") ||
|
|
|
|
|
lower.contains("protocol ") ||
|
|
|
|
|
lower.contains("extension ") ||
|
|
|
|
|
lower.contains("import appkit") ||
|
|
|
|
|
lower.contains("import uikit") ||
|
|
|
|
|
lower.contains("import foundationmodels") ||
|
|
|
|
|
lower.contains("guard ") ||
|
|
|
|
|
lower.contains("if let ")
|
|
|
|
|
)
|
|
|
|
|
if swiftStrongTokens {
|
|
|
|
|
tabs[index].language = "swift"
|
|
|
|
|
tabs[index].languageLocked = true
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !tabs[index].languageLocked {
|
|
|
|
|
// If the tab name has a known extension, honor it and lock
|
|
|
|
|
let nameExt = URL(fileURLWithPath: tabs[index].name).pathExtension.lowercased()
|
|
|
|
|
if let extLang = languageMap[nameExt], !extLang.isEmpty {
|
|
|
|
|
// If the extension suggests C# but content looks like Swift, prefer Swift and do not lock.
|
|
|
|
|
if extLang == "csharp" {
|
|
|
|
|
let looksSwift = lower.contains("import swiftui") || lower.contains(": view") || lower.contains("@main") || lower.contains(" final class ")
|
|
|
|
|
if looksSwift {
|
|
|
|
|
tabs[index].language = "swift"
|
|
|
|
|
tabs[index].languageLocked = true
|
|
|
|
|
} else {
|
|
|
|
|
tabs[index].language = extLang
|
|
|
|
|
tabs[index].languageLocked = true
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
tabs[index].language = extLang
|
|
|
|
|
tabs[index].languageLocked = true
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
let result = LanguageDetector.shared.detect(text: content, name: tabs[index].name, fileURL: tabs[index].fileURL)
|
|
|
|
|
let detected = result.lang
|
|
|
|
|
let scores = result.scores
|
|
|
|
|
let current = tabs[index].language
|
|
|
|
|
let swiftScore = scores["swift"] ?? 0
|
|
|
|
|
let csharpScore = scores["csharp"] ?? 0
|
|
|
|
|
|
|
|
|
|
// Derive strong Swift tokens and C# context similar to the detector to control switching behavior
|
|
|
|
|
// (let lower = content.lowercased()) -- removed duplicate since defined above
|
|
|
|
|
let swiftStrongTokens: Bool = (
|
|
|
|
|
lower.contains(" final class ") ||
|
|
|
|
|
lower.contains("public final class ") ||
|
|
|
|
|
lower.contains(": view") ||
|
|
|
|
|
lower.contains("@published") ||
|
|
|
|
|
lower.contains("@stateobject") ||
|
|
|
|
|
lower.contains("@mainactor") ||
|
|
|
|
|
lower.contains("protocol ") ||
|
|
|
|
|
lower.contains("extension ") ||
|
|
|
|
|
lower.contains("import swiftui") ||
|
|
|
|
|
lower.contains("import appkit") ||
|
|
|
|
|
lower.contains("import uikit") ||
|
|
|
|
|
lower.contains("import foundationmodels") ||
|
|
|
|
|
lower.contains("guard ") ||
|
|
|
|
|
lower.contains("if let ")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
let hasUsingSystem = lower.contains("\nusing system;") || lower.contains("\nusing system.")
|
|
|
|
|
let hasNamespace = lower.contains("\nnamespace ")
|
|
|
|
|
let hasMainMethod = lower.contains("static void main(") || lower.contains("static int main(")
|
|
|
|
|
let hasCSharpAttributes = (lower.contains("\n[") && lower.contains("]\n") && !lower.contains("@"))
|
|
|
|
|
let csharpContext = hasUsingSystem || hasNamespace || hasMainMethod || hasCSharpAttributes
|
|
|
|
|
|
|
|
|
|
// Avoid switching from Swift to C# unless there is very strong C# evidence and margin
|
|
|
|
|
if current == "swift" && detected == "csharp" {
|
|
|
|
|
let requireMargin = 25
|
|
|
|
|
if swiftStrongTokens && !csharpContext {
|
|
|
|
|
// Keep Swift when Swift-only tokens are present and no C# context exists
|
|
|
|
|
} else if !(csharpContext && csharpScore >= swiftScore + requireMargin) {
|
|
|
|
|
// Not enough evidence to switch away from Swift
|
|
|
|
|
} else {
|
|
|
|
|
tabs[index].language = "csharp"
|
|
|
|
|
tabs[index].languageLocked = false
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2026-02-11 10:20:17 +00:00
|
|
|
// Never downgrade an already-detected language to plain while editing.
|
|
|
|
|
// This avoids syntax-highlight flicker when detector confidence drops temporarily.
|
|
|
|
|
if detected == "plain" && current != "plain" {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-06 13:29:34 +00:00
|
|
|
// For all other cases, accept the detection
|
|
|
|
|
tabs[index].language = detected
|
|
|
|
|
// If Swift is confidently detected or Swift-only tokens are present, lock to prevent flip-flops
|
|
|
|
|
if detected == "swift" && (result.confidence >= 5 || swiftStrongTokens) {
|
|
|
|
|
tabs[index].languageLocked = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-25 09:01:45 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-14 13:24:01 +00:00
|
|
|
|
|
|
|
|
// Manually sets language and locks automatic switching.
|
2025-09-25 09:01:45 +00:00
|
|
|
func updateTabLanguage(tab: TabData, language: String) {
|
|
|
|
|
if let index = tabs.firstIndex(where: { $0.id == tab.id }) {
|
|
|
|
|
tabs[index].language = language
|
2026-02-06 13:29:34 +00:00
|
|
|
tabs[index].languageLocked = true
|
2025-09-25 09:01:45 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-14 13:24:01 +00:00
|
|
|
|
|
|
|
|
// Closes a tab while guaranteeing one tab remains open.
|
2025-09-25 09:01:45 +00:00
|
|
|
func closeTab(tab: TabData) {
|
|
|
|
|
tabs.removeAll { $0.id == tab.id }
|
|
|
|
|
if tabs.isEmpty {
|
|
|
|
|
addNewTab()
|
|
|
|
|
} else if selectedTabID == tab.id {
|
|
|
|
|
selectedTabID = tabs.first?.id
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-14 13:24:01 +00:00
|
|
|
|
|
|
|
|
// Saves tab content to the existing file URL or falls back to Save As.
|
2025-09-25 09:01:45 +00:00
|
|
|
func saveFile(tab: TabData) {
|
|
|
|
|
guard let index = tabs.firstIndex(where: { $0.id == tab.id }) else { return }
|
|
|
|
|
if let url = tabs[index].fileURL {
|
2025-09-25 09:00:22 +00:00
|
|
|
do {
|
2026-02-18 19:19:49 +00:00
|
|
|
let saveInterval = Self.saveSignposter.beginInterval("save_file")
|
|
|
|
|
defer { Self.saveSignposter.endInterval("save_file", saveInterval) }
|
2026-02-11 10:20:17 +00:00
|
|
|
let clean = sanitizeTextForEditor(tabs[index].content)
|
|
|
|
|
tabs[index].content = clean
|
2026-02-18 19:19:49 +00:00
|
|
|
let fingerprint = contentFingerprint(clean)
|
|
|
|
|
if tabs[index].lastSavedFingerprint == fingerprint, FileManager.default.fileExists(atPath: url.path) {
|
|
|
|
|
tabs[index].isDirty = false
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-11 10:20:17 +00:00
|
|
|
try clean.write(to: url, atomically: true, encoding: .utf8)
|
2026-02-06 19:20:03 +00:00
|
|
|
tabs[index].isDirty = false
|
2026-02-18 19:19:49 +00:00
|
|
|
tabs[index].lastSavedFingerprint = fingerprint
|
2025-09-25 09:00:22 +00:00
|
|
|
} catch {
|
2026-02-07 22:56:52 +00:00
|
|
|
debugLog("Failed to save file.")
|
2025-09-25 09:00:22 +00:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
saveFileAs(tab: tab)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-14 13:24:01 +00:00
|
|
|
|
|
|
|
|
// Saves tab content to a user-selected path on macOS.
|
2025-09-25 09:01:45 +00:00
|
|
|
func saveFileAs(tab: TabData) {
|
|
|
|
|
guard let index = tabs.firstIndex(where: { $0.id == tab.id }) else { return }
|
2026-02-07 10:51:52 +00:00
|
|
|
#if os(macOS)
|
2025-09-25 09:01:45 +00:00
|
|
|
let panel = NSSavePanel()
|
|
|
|
|
panel.nameFieldStringValue = tabs[index].name
|
2026-01-25 13:06:31 +00:00
|
|
|
let mdType = UTType(filenameExtension: "md") ?? .plainText
|
2026-02-05 21:30:21 +00:00
|
|
|
panel.allowedContentTypes = [
|
|
|
|
|
.text,
|
|
|
|
|
.swiftSource,
|
|
|
|
|
.pythonScript,
|
|
|
|
|
.javaScript,
|
|
|
|
|
.html,
|
|
|
|
|
.css,
|
|
|
|
|
.cSource,
|
|
|
|
|
.json,
|
2026-02-06 13:29:34 +00:00
|
|
|
mdType
|
2026-02-05 21:30:21 +00:00
|
|
|
]
|
2026-01-17 11:11:26 +00:00
|
|
|
|
|
|
|
|
if panel.runModal() == .OK, let url = panel.url {
|
|
|
|
|
do {
|
2026-02-18 19:19:49 +00:00
|
|
|
let saveAsInterval = Self.saveSignposter.beginInterval("save_file_as")
|
|
|
|
|
defer { Self.saveSignposter.endInterval("save_file_as", saveAsInterval) }
|
2026-02-11 10:20:17 +00:00
|
|
|
let clean = sanitizeTextForEditor(tabs[index].content)
|
|
|
|
|
tabs[index].content = clean
|
|
|
|
|
try clean.write(to: url, atomically: true, encoding: .utf8)
|
2026-01-17 11:11:26 +00:00
|
|
|
tabs[index].fileURL = url
|
|
|
|
|
tabs[index].name = url.lastPathComponent
|
2026-02-08 01:03:34 +00:00
|
|
|
if let mapped = LanguageDetector.shared.preferredLanguage(for: url) ?? languageMap[url.pathExtension.lowercased()] {
|
|
|
|
|
tabs[index].language = mapped
|
|
|
|
|
tabs[index].languageLocked = true
|
|
|
|
|
}
|
2026-02-06 19:20:03 +00:00
|
|
|
tabs[index].isDirty = false
|
2026-02-18 19:19:49 +00:00
|
|
|
tabs[index].lastSavedFingerprint = contentFingerprint(clean)
|
2026-01-17 11:11:26 +00:00
|
|
|
} catch {
|
2026-02-07 22:56:52 +00:00
|
|
|
debugLog("Failed to save file.")
|
2025-09-25 09:00:22 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
#else
|
|
|
|
|
// iOS/iPadOS: explicit Save As panel is not available here yet.
|
|
|
|
|
// Keep document dirty so user can export/share via future document APIs.
|
2026-02-07 22:56:52 +00:00
|
|
|
debugLog("Save As is currently only available on macOS.")
|
2026-02-07 10:51:52 +00:00
|
|
|
#endif
|
2025-09-25 09:00:22 +00:00
|
|
|
}
|
2026-02-14 13:24:01 +00:00
|
|
|
|
|
|
|
|
// Opens file-picker UI on macOS.
|
2025-09-25 09:01:45 +00:00
|
|
|
func openFile() {
|
2026-02-07 10:51:52 +00:00
|
|
|
#if os(macOS)
|
2025-09-25 09:01:45 +00:00
|
|
|
let panel = NSOpenPanel()
|
2026-01-23 11:49:52 +00:00
|
|
|
// Allow opening any file type, including hidden dotfiles like .zshrc
|
|
|
|
|
panel.allowedContentTypes = []
|
|
|
|
|
panel.allowsOtherFileTypes = true
|
2026-02-20 15:43:14 +00:00
|
|
|
panel.allowsMultipleSelection = true
|
2025-09-25 09:01:45 +00:00
|
|
|
panel.canChooseDirectories = false
|
2026-01-23 11:49:52 +00:00
|
|
|
panel.showsHiddenFiles = true
|
2026-01-17 11:11:26 +00:00
|
|
|
|
2026-02-20 15:43:14 +00:00
|
|
|
if panel.runModal() == .OK {
|
|
|
|
|
let urls = panel.urls
|
|
|
|
|
for url in urls {
|
|
|
|
|
openFile(url: url)
|
|
|
|
|
}
|
2025-09-25 09:01:45 +00:00
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
#else
|
|
|
|
|
// iOS/iPadOS: document picker flow can be added here.
|
2026-02-07 22:56:52 +00:00
|
|
|
debugLog("Open File panel is currently only available on macOS.")
|
2026-02-07 10:51:52 +00:00
|
|
|
#endif
|
2025-09-25 09:01:45 +00:00
|
|
|
}
|
2026-02-14 13:24:01 +00:00
|
|
|
|
|
|
|
|
// Loads a file into a new tab unless the file is already open.
|
2026-01-25 12:46:33 +00:00
|
|
|
func openFile(url: URL) {
|
2026-02-08 23:43:57 +00:00
|
|
|
if focusTabIfOpen(for: url) { return }
|
2026-01-25 12:46:33 +00:00
|
|
|
do {
|
2026-02-11 10:20:17 +00:00
|
|
|
let raw = try String(contentsOf: url, encoding: .utf8)
|
|
|
|
|
let content = sanitizeTextForEditor(raw)
|
2026-02-08 01:03:34 +00:00
|
|
|
let extLang = LanguageDetector.shared.preferredLanguage(for: url) ?? languageMap[url.pathExtension.lowercased()]
|
2026-02-06 13:29:34 +00:00
|
|
|
let detectedLang = extLang ?? LanguageDetector.shared.detect(text: content, name: url.lastPathComponent, fileURL: url).lang
|
2026-01-25 12:46:33 +00:00
|
|
|
let newTab = TabData(name: url.lastPathComponent,
|
|
|
|
|
content: content,
|
2026-02-06 13:29:34 +00:00
|
|
|
language: detectedLang,
|
|
|
|
|
fileURL: url,
|
2026-02-06 19:20:03 +00:00
|
|
|
languageLocked: extLang != nil,
|
2026-02-18 19:19:49 +00:00
|
|
|
isDirty: false,
|
|
|
|
|
lastSavedFingerprint: contentFingerprint(content))
|
2026-01-25 12:46:33 +00:00
|
|
|
tabs.append(newTab)
|
|
|
|
|
selectedTabID = newTab.id
|
|
|
|
|
} catch {
|
2026-02-07 22:56:52 +00:00
|
|
|
debugLog("Failed to open file.")
|
2026-01-25 12:46:33 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
private func sanitizeTextForEditor(_ input: String) -> String {
|
|
|
|
|
EditorTextSanitizer.sanitize(input)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 19:19:49 +00:00
|
|
|
private func contentFingerprint(_ text: String) -> UInt64 {
|
|
|
|
|
var hasher = Hasher()
|
|
|
|
|
hasher.combine(text)
|
|
|
|
|
let value = hasher.finalize()
|
|
|
|
|
return UInt64(bitPattern: Int64(value))
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
|
2026-02-08 23:43:57 +00:00
|
|
|
func hasOpenFile(url: URL) -> Bool {
|
|
|
|
|
indexOfOpenTab(for: url) != nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 13:24:01 +00:00
|
|
|
// Focuses an existing tab for URL if present.
|
2026-02-08 23:43:57 +00:00
|
|
|
func focusTabIfOpen(for url: URL) -> Bool {
|
|
|
|
|
if let existingIndex = indexOfOpenTab(for: url) {
|
|
|
|
|
selectedTabID = tabs[existingIndex].id
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 23:27:54 +00:00
|
|
|
private func indexOfOpenTab(for url: URL) -> Int? {
|
|
|
|
|
let target = url.resolvingSymlinksInPath().standardizedFileURL
|
|
|
|
|
return tabs.firstIndex { tab in
|
|
|
|
|
guard let fileURL = tab.fileURL else { return false }
|
|
|
|
|
return fileURL.resolvingSymlinksInPath().standardizedFileURL == target
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 13:24:01 +00:00
|
|
|
// Marks a tab clean after successful save/export and updates URL-derived metadata.
|
2026-02-07 10:51:52 +00:00
|
|
|
func markTabSaved(tabID: UUID, fileURL: URL? = nil) {
|
|
|
|
|
guard let index = tabs.firstIndex(where: { $0.id == tabID }) else { return }
|
|
|
|
|
if let fileURL {
|
|
|
|
|
tabs[index].fileURL = fileURL
|
|
|
|
|
tabs[index].name = fileURL.lastPathComponent
|
2026-02-08 01:03:34 +00:00
|
|
|
if let mapped = LanguageDetector.shared.preferredLanguage(for: fileURL) ?? languageMap[fileURL.pathExtension.lowercased()] {
|
|
|
|
|
tabs[index].language = mapped
|
|
|
|
|
tabs[index].languageLocked = true
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
}
|
|
|
|
|
tabs[index].isDirty = false
|
2026-02-18 19:19:49 +00:00
|
|
|
tabs[index].lastSavedFingerprint = contentFingerprint(tabs[index].content)
|
2026-02-07 10:51:52 +00:00
|
|
|
}
|
2026-02-14 13:24:01 +00:00
|
|
|
|
|
|
|
|
// Returns whitespace-delimited word count for status display.
|
2025-09-25 09:01:45 +00:00
|
|
|
func wordCount(for text: String) -> Int {
|
2026-02-19 14:29:53 +00:00
|
|
|
text.split(whereSeparator: \.isWhitespace).count
|
2025-09-25 09:01:45 +00:00
|
|
|
}
|
2026-02-07 22:56:52 +00:00
|
|
|
|
|
|
|
|
private func debugLog(_ message: String) {
|
|
|
|
|
#if DEBUG
|
|
|
|
|
print(message)
|
|
|
|
|
#endif
|
|
|
|
|
}
|
2026-02-12 22:20:39 +00:00
|
|
|
|
2026-02-14 13:24:01 +00:00
|
|
|
// Reads user preference for default language of newly created tabs.
|
2026-02-12 22:20:39 +00:00
|
|
|
private func defaultNewTabLanguage() -> String {
|
|
|
|
|
let stored = UserDefaults.standard.string(forKey: "SettingsDefaultNewFileLanguage") ?? "plain"
|
|
|
|
|
let trimmed = stored.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
|
|
return trimmed.isEmpty ? "plain" : trimmed
|
|
|
|
|
}
|
2025-09-25 09:01:45 +00:00
|
|
|
}
|