diff --git a/Info-macOS.plist b/Info-macOS.plist index b720fb9..f53789e 100644 --- a/Info-macOS.plist +++ b/Info-macOS.plist @@ -21,11 +21,13 @@ phtml csv tsv + txt toml ini yaml yml xml + plist sql log vim @@ -40,6 +42,9 @@ psm1 html htm + ee + exp + tmpl css c cpp @@ -69,6 +74,13 @@ conf nginx + LSItemContentTypes + + public.plain-text + public.text + public.source-code + net.daringfireball.markdown + CFBundleTypeName Neon Vision Editor Document CFBundleTypeRole diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index 9c586ad..68a5dd5 100644 --- a/Neon Vision Editor.xcodeproj/project.pbxproj +++ b/Neon Vision Editor.xcodeproj/project.pbxproj @@ -358,7 +358,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 323; + CURRENT_PROJECT_VERSION = 324; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; @@ -439,7 +439,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 323; + CURRENT_PROJECT_VERSION = 324; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; diff --git a/Neon Vision Editor/Core/LanguageDetector.swift b/Neon Vision Editor/Core/LanguageDetector.swift index 8e38492..f8dba80 100644 --- a/Neon Vision Editor/Core/LanguageDetector.swift +++ b/Neon Vision Editor/Core/LanguageDetector.swift @@ -22,6 +22,7 @@ public struct LanguageDetector { "phtml": "php", "csv": "csv", "tsv": "csv", + "txt": "plain", "toml": "toml", "ini": "ini", "yaml": "yaml", @@ -126,15 +127,21 @@ public struct LanguageDetector { } // Extension/dotfile hint + var extensionHint: String? if let byURL = preferredLanguage(for: fileURL) { + extensionHint = byURL bump(byURL, 300) } else if let name { let lowerName = name.lowercased() if let mapped = dotfileMap[lowerName] { + extensionHint = mapped bump(mapped, 300) } else { let ext = URL(fileURLWithPath: lowerName).pathExtension.lowercased() - if let mapped = extensionMap[ext] { bump(mapped, 300) } + if let mapped = extensionMap[ext] { + extensionHint = mapped + bump(mapped, 300) + } } } @@ -341,7 +348,11 @@ public struct LanguageDetector { let second = sorted.dropFirst().first ?? ("plain", 0) let confidence = max(0, top.value - second.value) let minScore = 10 - let lang = top.value >= minScore ? top.key : "plain" + var lang = top.value >= minScore ? top.key : "plain" + // Keep explicit Markdown filenames stable even if body text contains SQL-like terms. + if extensionHint == "markdown" { + lang = "markdown" + } return Result(lang: lang, scores: scores, confidence: confidence) } diff --git a/Neon Vision Editor/Data/EditorViewModel.swift b/Neon Vision Editor/Data/EditorViewModel.swift index 4f9507b..d4c3698 100644 --- a/Neon Vision Editor/Data/EditorViewModel.swift +++ b/Neon Vision Editor/Data/EditorViewModel.swift @@ -383,6 +383,7 @@ class EditorViewModel: ObservableObject { "phtml": "php", "csv": "csv", "tsv": "csv", + "txt": "plain", "toml": "toml", "ini": "ini", "yaml": "yaml", diff --git a/Neon Vision Editor/UI/ContentView.swift b/Neon Vision Editor/UI/ContentView.swift index 5f25e79..a04c2f8 100644 --- a/Neon Vision Editor/UI/ContentView.swift +++ b/Neon Vision Editor/UI/ContentView.swift @@ -1686,6 +1686,9 @@ struct ContentView: View { persistUnsavedDraftSnapshotIfNeeded() #endif } + .onOpenURL { url in + viewModel.openFile(url: url) + } #if os(iOS) .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in persistSessionIfReady() @@ -2014,32 +2017,141 @@ struct ContentView: View { UserDefaults.standard.set(viewModel.selectedTab?.fileURL?.absoluteString, forKey: "LastSessionSelectedFileURL") #if os(iOS) persistLastSessionSecurityScopedBookmarks(fileURLs: fileURLs, selectedURL: viewModel.selectedTab?.fileURL) +#elseif os(macOS) + persistLastSessionSecurityScopedBookmarksMac(fileURLs: fileURLs, selectedURL: viewModel.selectedTab?.fileURL) #endif } private func restoredLastSessionFileURLs() -> [URL] { -#if os(iOS) +#if os(macOS) + let bookmarked = restoreSessionURLsFromSecurityScopedBookmarksMac() + if !bookmarked.isEmpty { + return bookmarked + } +#elseif os(iOS) let bookmarked = restoreSessionURLsFromSecurityScopedBookmarks() if !bookmarked.isEmpty { return bookmarked } #endif - let paths = UserDefaults.standard.stringArray(forKey: "LastSessionFileURLs") ?? [] - return paths.compactMap(URL.init(string:)) + let stored = UserDefaults.standard.stringArray(forKey: "LastSessionFileURLs") ?? [] + var urls: [URL] = [] + var seen: Set = [] + for raw in stored { + guard let parsed = restoredSessionURL(from: raw) else { continue } + let standardized = parsed.standardizedFileURL + // Only restore files that still exist; avoids empty placeholder tabs on launch. + guard FileManager.default.fileExists(atPath: standardized.path) else { continue } + let key = standardized.absoluteString + if seen.insert(key).inserted { + urls.append(standardized) + } + } + return urls } private func restoredLastSessionSelectedFileURL() -> URL? { -#if os(iOS) +#if os(macOS) + if let bookmarked = restoreSelectedURLFromSecurityScopedBookmarkMac() { + return bookmarked + } +#elseif os(iOS) if let bookmarked = restoreSelectedURLFromSecurityScopedBookmark() { return bookmarked } #endif - guard let selectedPath = UserDefaults.standard.string(forKey: "LastSessionSelectedFileURL") else { + guard let selectedPath = UserDefaults.standard.string(forKey: "LastSessionSelectedFileURL"), + let selectedURL = restoredSessionURL(from: selectedPath) else { return nil } - return URL(string: selectedPath) + let standardized = selectedURL.standardizedFileURL + return FileManager.default.fileExists(atPath: standardized.path) ? standardized : nil } + private func restoredSessionURL(from raw: String) -> URL? { + // Support both absolute URL strings ("file:///...") and legacy plain paths. + if let url = URL(string: raw), url.isFileURL { + return url + } + if raw.hasPrefix("/") { + return URL(fileURLWithPath: raw) + } + return nil + } + +#if os(macOS) + private var macLastSessionBookmarksKey: String { "MacLastSessionFileBookmarks" } + private var macLastSessionSelectedBookmarkKey: String { "MacLastSessionSelectedFileBookmark" } + + private func persistLastSessionSecurityScopedBookmarksMac(fileURLs: [URL], selectedURL: URL?) { + let bookmarkData = fileURLs.compactMap { makeSecurityScopedBookmarkDataMac(for: $0) } + UserDefaults.standard.set(bookmarkData, forKey: macLastSessionBookmarksKey) + if let selectedURL, let selectedData = makeSecurityScopedBookmarkDataMac(for: selectedURL) { + UserDefaults.standard.set(selectedData, forKey: macLastSessionSelectedBookmarkKey) + } else { + UserDefaults.standard.removeObject(forKey: macLastSessionSelectedBookmarkKey) + } + } + + private func restoreSessionURLsFromSecurityScopedBookmarksMac() -> [URL] { + guard let saved = UserDefaults.standard.array(forKey: macLastSessionBookmarksKey) as? [Data], !saved.isEmpty else { + return [] + } + var urls: [URL] = [] + var seen: Set = [] + for data in saved { + guard let url = resolveSecurityScopedBookmarkMac(data) else { continue } + let standardized = url.standardizedFileURL + guard FileManager.default.fileExists(atPath: standardized.path) else { continue } + let key = standardized.absoluteString + if seen.insert(key).inserted { + urls.append(standardized) + } + } + return urls + } + + private func restoreSelectedURLFromSecurityScopedBookmarkMac() -> URL? { + guard let data = UserDefaults.standard.data(forKey: macLastSessionSelectedBookmarkKey), + let resolved = resolveSecurityScopedBookmarkMac(data) else { + return nil + } + let standardized = resolved.standardizedFileURL + return FileManager.default.fileExists(atPath: standardized.path) ? standardized : nil + } + + private func makeSecurityScopedBookmarkDataMac(for url: URL) -> Data? { + let didStartScopedAccess = url.startAccessingSecurityScopedResource() + defer { + if didStartScopedAccess { + url.stopAccessingSecurityScopedResource() + } + } + do { + return try url.bookmarkData( + options: [.withSecurityScope], + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + } catch { + return nil + } + } + + private func resolveSecurityScopedBookmarkMac(_ data: Data) -> URL? { + var isStale = false + guard let resolved = try? URL( + resolvingBookmarkData: data, + options: [.withSecurityScope, .withoutUI], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) else { + return nil + } + return resolved + } +#endif + #if os(iOS) private var unsavedDraftSnapshotKey: String { "IOSUnsavedDraftSnapshotV1" } private var lastSessionBookmarksKey: String { "LastSessionFileBookmarks" } diff --git a/Neon Vision EditorTests/LanguageDetectorTests.swift b/Neon Vision EditorTests/LanguageDetectorTests.swift index 51b5490..0763336 100644 --- a/Neon Vision EditorTests/LanguageDetectorTests.swift +++ b/Neon Vision EditorTests/LanguageDetectorTests.swift @@ -23,6 +23,7 @@ final class LanguageDetectorTests: XCTestCase { ("main.yml", "yaml"), ("main.toml", "toml"), ("main.csv", "csv"), + ("main.txt", "plain"), ("main.ini", "ini"), ("main.md", "markdown"), ("main.proto", "proto"), @@ -104,4 +105,16 @@ final class LanguageDetectorTests: XCTestCase { let result = LanguageDetector.shared.detect(text: "", name: nil, fileURL: nil) XCTAssertEqual(result.lang, "plain") } + + func testMarkdownExtensionNotOverriddenBySQLHeuristics() { + let text = """ + # History vision + + Concrete API plan: + SELECT endpoint, method FROM api_catalog WHERE active = 1; + """ + let url = URL(fileURLWithPath: "/tmp/History vision Concrete API plan.md") + let result = LanguageDetector.shared.detect(text: text, name: url.lastPathComponent, fileURL: url) + XCTAssertEqual(result.lang, "markdown") + } }