diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index 7150e11..3fa90da 100644 --- a/Neon Vision Editor.xcodeproj/project.pbxproj +++ b/Neon Vision Editor.xcodeproj/project.pbxproj @@ -361,7 +361,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 430; + CURRENT_PROJECT_VERSION = 431; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; @@ -444,7 +444,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 430; + CURRENT_PROJECT_VERSION = 431; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; diff --git a/Neon Vision Editor/Core/EditorPerformanceMonitor.swift b/Neon Vision Editor/Core/EditorPerformanceMonitor.swift index b83695a..cc756ad 100644 --- a/Neon Vision Editor/Core/EditorPerformanceMonitor.swift +++ b/Neon Vision Editor/Core/EditorPerformanceMonitor.swift @@ -3,6 +3,14 @@ import OSLog @MainActor final class EditorPerformanceMonitor { + struct FileOpenEvent: Codable, Identifiable { + let id: UUID + let timestamp: Date + let elapsedMilliseconds: Int + let success: Bool + let byteCount: Int? + } + static let shared = EditorPerformanceMonitor() private let logger = Logger(subsystem: "h3p.Neon-Vision-Editor", category: "Performance") @@ -10,6 +18,9 @@ final class EditorPerformanceMonitor { private var didLogFirstPaint = false private var didLogFirstKeystroke = false private var fileOpenStartUptimeByTabID: [UUID: TimeInterval] = [:] + private let defaults = UserDefaults.standard + private let eventsDefaultsKey = "PerformanceRecentFileOpenEventsV1" + private let maxEvents = 30 private init() {} @@ -41,8 +52,17 @@ final class EditorPerformanceMonitor { func endFileOpen(tabID: UUID, success: Bool, byteCount: Int?) { guard let startedAt = fileOpenStartUptimeByTabID.removeValue(forKey: tabID) else { return } -#if DEBUG let elapsed = Self.elapsedMilliseconds(since: startedAt) + storeFileOpenEvent( + FileOpenEvent( + id: UUID(), + timestamp: Date(), + elapsedMilliseconds: elapsed, + success: success, + byteCount: byteCount + ) + ) +#if DEBUG if let byteCount { logger.debug( "perf.file_open_ms=\(elapsed, privacy: .public) success=\(success, privacy: .public) bytes=\(byteCount, privacy: .public)" @@ -53,6 +73,25 @@ final class EditorPerformanceMonitor { #endif } + func recentFileOpenEvents(limit: Int = 10) -> [FileOpenEvent] { + guard let data = defaults.data(forKey: eventsDefaultsKey), + let decoded = try? JSONDecoder().decode([FileOpenEvent].self, from: data) else { + return [] + } + let clamped = max(1, min(limit, maxEvents)) + return Array(decoded.suffix(clamped)) + } + + private func storeFileOpenEvent(_ event: FileOpenEvent) { + var existing = recentFileOpenEvents(limit: maxEvents) + existing.append(event) + if existing.count > maxEvents { + existing.removeFirst(existing.count - maxEvents) + } + guard let encoded = try? JSONEncoder().encode(existing) else { return } + defaults.set(encoded, forKey: eventsDefaultsKey) + } + private static func elapsedMilliseconds(since startUptime: TimeInterval) -> Int { max(0, Int((ProcessInfo.processInfo.systemUptime - startUptime) * 1_000)) } diff --git a/Neon Vision Editor/Core/SyntaxHighlighting.swift b/Neon Vision Editor/Core/SyntaxHighlighting.swift index 066466f..0efaf65 100644 --- a/Neon Vision Editor/Core/SyntaxHighlighting.swift +++ b/Neon Vision Editor/Core/SyntaxHighlighting.swift @@ -236,11 +236,11 @@ func getSyntaxPatterns( return [ #"(?m)^\s{0,3}#{1,6}\s+.*$"#: colors.meta, #"(?m)^\s{0,3}(=+|-+)\s*$"#: colors.meta, - #"`{1,3}[^`]+`{1,3}"#: colors.string, + #"(?s)```.*?```|~~~.*?~~~|`{1,3}[^`\n]+`{1,3}"#: colors.string, #"(?m)^```[A-Za-z0-9_-]*\s*$|(?m)^~~~[A-Za-z0-9_-]*\s*$"#: colors.keyword, #"(?m)^\s*[-*+]\s+.*$|(?m)^\s*\d+\.\s+.*$"#: colors.keyword, #"\*\*[^*\n]+\*\*|__[^_\n]+__"#: colors.def, - #"(?\s+.*$"#: colors.comment ] diff --git a/Neon Vision Editor/Data/EditorViewModel.swift b/Neon Vision Editor/Data/EditorViewModel.swift index 80d0c61..d66cf0e 100644 --- a/Neon Vision Editor/Data/EditorViewModel.swift +++ b/Neon Vision Editor/Data/EditorViewModel.swift @@ -118,6 +118,7 @@ private struct EditorFileLoadResult: Sendable { let detectedLanguage: String let languageLocked: Bool let fingerprint: UInt64? + let fileModificationDate: Date? let isLargeCandidate: Bool } @@ -309,6 +310,7 @@ final class TabData: Identifiable { fileprivate(set) var languageLocked: Bool fileprivate(set) var isDirty: Bool fileprivate(set) var lastSavedFingerprint: UInt64? + fileprivate(set) var lastKnownFileModificationDate: Date? fileprivate(set) var isLoadingContent: Bool fileprivate(set) var isLargeFileCandidate: Bool @@ -321,6 +323,7 @@ final class TabData: Identifiable { languageLocked: Bool = false, isDirty: Bool = false, lastSavedFingerprint: UInt64? = nil, + lastKnownFileModificationDate: Date? = nil, isLoadingContent: Bool = false, isLargeFileCandidate: Bool = false ) { @@ -332,6 +335,7 @@ final class TabData: Identifiable { self.languageLocked = languageLocked self.isDirty = isDirty self.lastSavedFingerprint = lastSavedFingerprint + self.lastKnownFileModificationDate = lastKnownFileModificationDate self.isLoadingContent = isLoadingContent self.isLargeFileCandidate = isLargeFileCandidate } @@ -383,6 +387,10 @@ final class TabData: Identifiable { lastSavedFingerprint = fingerprint } + func updateLastKnownFileModificationDate(_ date: Date?) { + lastKnownFileModificationDate = date + } + func resetContentRevision() { contentRevision = 0 } @@ -393,6 +401,17 @@ final class TabData: Identifiable { @MainActor @Observable class EditorViewModel { + struct ExternalFileConflictState: Sendable { + let tabID: UUID + let fileURL: URL + let diskModifiedAt: Date? + } + + struct ExternalFileComparisonSnapshot: Sendable { + let fileName: String + let localContent: String + let diskContent: String + } private actor TabCommandQueue { private var isLocked = false private var waiters: [CheckedContinuation] = [] @@ -424,6 +443,7 @@ class EditorViewModel { private static let deferredLanguageDetectionSampleUTF16Length = 180_000 private(set) var tabs: [TabData] = [] private(set) var selectedTabID: UUID? + var pendingExternalFileConflict: ExternalFileConflictState? var showSidebar: Bool = true var isBrainDumpMode: Bool = false var showingRename: Bool = false @@ -494,11 +514,12 @@ class EditorViewModel { let languageLocked: Bool let isDirty: Bool let lastSavedFingerprint: UInt64? + let lastKnownFileModificationDate: Date? } private enum TabCommand: Sendable { case updateContent(tabID: UUID, mutation: TabContentMutation) - case markSaved(tabID: UUID, fileURL: URL?, fingerprint: UInt64?) + case markSaved(tabID: UUID, fileURL: URL?, fingerprint: UInt64?, fileModificationDate: Date?) case setLanguage(tabID: UUID, language: String, lock: Bool) case closeTab(tabID: UUID) case addNewTab(name: String, language: String) @@ -523,6 +544,7 @@ class EditorViewModel { language: String, languageLocked: Bool, fingerprint: UInt64?, + fileModificationDate: Date?, isLargeCandidate: Bool ) } @@ -553,7 +575,7 @@ class EditorViewModel { } return outcome - case let .markSaved(tabID, fileURL, fingerprint): + case let .markSaved(tabID, fileURL, fingerprint, fileModificationDate): guard let index = tabIndex(for: tabID) else { return TabCommandOutcome() } let outcome = TabCommandOutcome(index: index) if let fileURL { @@ -566,6 +588,7 @@ class EditorViewModel { } } tabs[index].markClean(withFingerprint: fingerprint) + tabs[index].updateLastKnownFileModificationDate(fileModificationDate) recordTabStateMutation(rebuildIndexes: true) return outcome @@ -662,7 +685,8 @@ class EditorViewModel { fileURL: snapshot.fileURL, languageLocked: snapshot.languageLocked, isDirty: snapshot.isDirty, - lastSavedFingerprint: snapshot.lastSavedFingerprint + lastSavedFingerprint: snapshot.lastSavedFingerprint, + lastKnownFileModificationDate: snapshot.lastKnownFileModificationDate ) ) } @@ -710,11 +734,12 @@ class EditorViewModel { recordTabStateMutation() return TabCommandOutcome(index: index) - case let .applyLoadedTabState(tabID, content, language, languageLocked, fingerprint, isLargeCandidate): + case let .applyLoadedTabState(tabID, content, language, languageLocked, fingerprint, fileModificationDate, isLargeCandidate): guard let index = tabIndex(for: tabID) else { return TabCommandOutcome() } tabs[index].language = language tabs[index].languageLocked = languageLocked tabs[index].markClean(withFingerprint: fingerprint) + tabs[index].updateLastKnownFileModificationDate(fileModificationDate) tabs[index].isLargeFileCandidate = isLargeCandidate let didChange = tabs[index].replaceContentStorage( with: content, @@ -958,8 +983,13 @@ class EditorViewModel { } // Saves tab content to the existing file URL or falls back to Save As. - func saveFile(tabID: UUID) { + func saveFile(tabID: UUID, allowExternalOverwrite: Bool = false) { guard let index = tabIndex(for: tabID) else { return } + if !allowExternalOverwrite, + let conflict = detectExternalConflict(for: tabs[index]) { + pendingExternalFileConflict = conflict + return + } if let url = tabs[index].fileURL { enqueueSave(tabID: tabID, to: url, updateFileURLOnSuccess: nil, signpostName: "save_file") } else { @@ -971,6 +1001,55 @@ class EditorViewModel { saveFile(tabID: tab.id) } + func resolveExternalConflictByKeepingLocal(tabID: UUID) { + pendingExternalFileConflict = nil + saveFile(tabID: tabID, allowExternalOverwrite: true) + } + + func resolveExternalConflictByReloadingDisk(tabID: UUID) { + pendingExternalFileConflict = nil + guard let index = tabIndex(for: tabID), + let url = tabs[index].fileURL else { return } + let isLargeCandidate = tabs[index].isLargeFileCandidate + let extLangHint = LanguageDetector.shared.preferredLanguage(for: url) ?? languageMap[url.pathExtension.lowercased()] + _ = applyTabCommand(.setLoading(tabID: tabID, isLoading: true)) + EditorPerformanceMonitor.shared.beginFileOpen(tabID: tabID) + Task { [weak self] in + guard let self else { return } + do { + let loadResult = try await Self.loadFileResult( + from: url, + extLangHint: extLangHint, + isLargeCandidate: isLargeCandidate + ) + await self.applyLoadedContent(tabID: tabID, result: loadResult) + } catch { + await self.markTabLoadFailed(tabID: tabID) + } + } + } + + func externalConflictComparisonSnapshot(tabID: UUID) async -> ExternalFileComparisonSnapshot? { + guard let index = tabIndex(for: tabID), + let url = tabs[index].fileURL else { return nil } + let fileName = tabs[index].name + let localContent = tabs[index].content + return await Task.detached(priority: .utility) { + let data = (try? Data(contentsOf: url, options: [.mappedIfSafe])) ?? Data() + let diskContent = String(decoding: data, as: UTF8.self) + return ExternalFileComparisonSnapshot( + fileName: fileName, + localContent: localContent, + diskContent: diskContent + ) + }.value + } + + func refreshExternalConflictForTab(tabID: UUID) { + guard let index = tabIndex(for: tabID) else { return } + pendingExternalFileConflict = detectExternalConflict(for: tabs[index]) + } + // Saves tab content to a user-selected path on macOS. func saveFileAs(tabID: UUID) { guard let index = tabIndex(for: tabID) else { return } @@ -1038,13 +1117,16 @@ class EditorViewModel { FileManager.default.fileExists(atPath: destinationURL.path) { if let finalIndex = self.tabIndex(for: tabID), self.tabs[finalIndex].contentRevision == expectedRevision { + let fileModificationDate = try? destinationURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate _ = self.applyTabCommand( .markSaved( tabID: tabID, fileURL: updateFileURLOnSuccess, - fingerprint: payload.fingerprint + fingerprint: payload.fingerprint, + fileModificationDate: fileModificationDate ) ) + self.pendingExternalFileConflict = nil } return } @@ -1061,16 +1143,31 @@ class EditorViewModel { return } + let fileModificationDate = try? destinationURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate _ = self.applyTabCommand( .markSaved( tabID: tabID, fileURL: updateFileURLOnSuccess, - fingerprint: payload.fingerprint + fingerprint: payload.fingerprint, + fileModificationDate: fileModificationDate ) ) + self.pendingExternalFileConflict = nil } } + private func detectExternalConflict(for tab: TabData) -> ExternalFileConflictState? { + guard tab.isDirty, let fileURL = tab.fileURL else { return nil } + guard let diskModifiedAt = try? fileURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate else { + return nil + } + guard let known = tab.lastKnownFileModificationDate else { return nil } + if diskModifiedAt.timeIntervalSince(known) > 0.5 { + return ExternalFileConflictState(tabID: tab.id, fileURL: fileURL, diskModifiedAt: diskModifiedAt) + } + return nil + } + // Opens file-picker UI on macOS. func openFile() { #if os(macOS) @@ -1146,6 +1243,7 @@ class EditorViewModel { url.stopAccessingSecurityScopedResource() } } + let initialModificationDate = try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate let data: Data if isLargeCandidate { @@ -1175,6 +1273,7 @@ class EditorViewModel { detectedLanguage: detectedLanguage, languageLocked: extLangHint != nil, fingerprint: fingerprint, + fileModificationDate: initialModificationDate, isLargeCandidate: data.count >= EditorLoadHelper.largeFileCandidateByteThreshold ) }.value @@ -1182,7 +1281,11 @@ class EditorViewModel { private nonisolated static func prepareSavePayload(from content: String) async -> EditorFileSavePayload { await Task.detached(priority: .userInitiated) { - let clean = EditorTextSanitizer.sanitize(content) + // Keep save path non-destructive: only normalize line endings and strip NUL. + let clean = content + .replacingOccurrences(of: "\0", with: "") + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") return EditorFileSavePayload( content: clean, fingerprint: Self.contentFingerprintValue(clean) @@ -1209,6 +1312,7 @@ class EditorViewModel { language: result.detectedLanguage, languageLocked: result.languageLocked, fingerprint: result.fingerprint, + fileModificationDate: result.fileModificationDate, isLargeCandidate: result.isLargeCandidate ) ) @@ -1430,7 +1534,8 @@ class EditorViewModel { .markSaved( tabID: tabID, fileURL: fileURL, - fingerprint: contentFingerprint(tabs[index].content) + fingerprint: contentFingerprint(tabs[index].content), + fileModificationDate: fileURL.flatMap { try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate } ) ) } diff --git a/Neon Vision Editor/UI/ContentView.swift b/Neon Vision Editor/UI/ContentView.swift index fda6111..f726d7e 100644 --- a/Neon Vision Editor/UI/ContentView.swift +++ b/Neon Vision Editor/UI/ContentView.swift @@ -233,6 +233,9 @@ struct ContentView: View { @State var projectFolderSecurityURL: URL? = nil @State var pendingCloseTabID: UUID? = nil @State var showUnsavedCloseDialog: Bool = false + @State private var showExternalConflictDialog: Bool = false + @State private var showExternalConflictCompareSheet: Bool = false + @State private var externalConflictCompareSnapshot: EditorViewModel.ExternalFileComparisonSnapshot? @State var showClearEditorConfirmDialog: Bool = false @State var showIOSFileImporter: Bool = false @State var showIOSFileExporter: Bool = false @@ -289,6 +292,8 @@ struct ContentView: View { @State private var didRunInitialWindowLayoutSetup: Bool = false @State private var pendingLargeFileModeReevaluation: DispatchWorkItem? = nil @State private var recoverySnapshotIdentifier: String = UUID().uuidString + @State private var lastCaretLocation: Int = 0 + @State private var sessionCaretByFileURL: [String: Int] = [:] private let quickSwitcherRecentsDefaultsKey = "QuickSwitcherRecentItemsV1" #if USE_FOUNDATION_MODELS && canImport(FoundationModels) @@ -1266,6 +1271,12 @@ struct ContentView: View { private func withBaseEditorEvents(_ view: Content) -> some View { let viewWithClipboardEvents = view .onReceive(NotificationCenter.default.publisher(for: .caretPositionDidChange)) { notif in + if let location = notif.userInfo?["location"] as? Int, location >= 0 { + lastCaretLocation = location + if let selectedURL = viewModel.selectedTab?.fileURL?.standardizedFileURL { + sessionCaretByFileURL[selectedURL.absoluteString] = location + } + } if let line = notif.userInfo?["line"] as? Int, let col = notif.userInfo?["column"] as? Int { if line <= 0 { caretStatus = "Pos \(col)" @@ -1345,6 +1356,11 @@ struct ContentView: View { updateLargeFileModeForCurrentContext() scheduleLargeFileModeReevaluation(after: 0.9) scheduleHighlightRefresh() + if let selectedID = viewModel.selectedTab?.id { + viewModel.refreshExternalConflictForTab(tabID: selectedID) + } + restoreCaretForSelectedSessionFileIfAvailable() + persistSessionIfReady() } .onChange(of: viewModel.selectedTab?.isLoadingContent ?? false) { _, isLoading in if isLoading { @@ -1359,6 +1375,11 @@ struct ContentView: View { .onChange(of: currentLanguage) { _, newValue in settingsTemplateLanguage = newValue } + .onChange(of: viewModel.pendingExternalFileConflict?.tabID) { _, conflictTabID in + if conflictTabID != nil { + showExternalConflictDialog = true + } + } } private func handlePastedTextNotification(_ notif: Notification) { @@ -1809,6 +1830,28 @@ struct ContentView: View { persistSessionIfReady() persistUnsavedDraftSnapshotIfNeeded() } + .onChange(of: viewModel.showSidebar) { _, _ in + persistSessionIfReady() + } + .onChange(of: showProjectStructureSidebar) { _, _ in + persistSessionIfReady() + } + .onChange(of: showMarkdownPreviewPane) { _, _ in + persistSessionIfReady() + } +#if os(iOS) + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + if let selectedID = viewModel.selectedTab?.id { + viewModel.refreshExternalConflictForTab(tabID: selectedID) + } + } +#elseif os(macOS) + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + if let selectedID = viewModel.selectedTab?.id { + viewModel.refreshExternalConflictForTab(tabID: selectedID) + } + } +#endif .onOpenURL { url in viewModel.openFile(url: url) } @@ -2081,6 +2124,83 @@ struct ContentView: View { Text("This file has unsaved changes.") } } + .confirmationDialog("File changed on disk", isPresented: contentView.$showExternalConflictDialog, titleVisibility: .visible) { + if let conflict = contentView.viewModel.pendingExternalFileConflict { + Button("Reload from Disk", role: .destructive) { + contentView.viewModel.resolveExternalConflictByReloadingDisk(tabID: conflict.tabID) + } + Button("Keep Local and Save") { + contentView.viewModel.resolveExternalConflictByKeepingLocal(tabID: conflict.tabID) + } + Button("Compare") { + Task { + if let snapshot = await contentView.viewModel.externalConflictComparisonSnapshot(tabID: conflict.tabID) { + await MainActor.run { + contentView.externalConflictCompareSnapshot = snapshot + contentView.showExternalConflictCompareSheet = true + } + } + } + } + } + Button("Cancel", role: .cancel) { } + } message: { + if let conflict = contentView.viewModel.pendingExternalFileConflict { + if let modified = conflict.diskModifiedAt { + Text("\"\(conflict.fileURL.lastPathComponent)\" changed on disk at \(modified.formatted(date: .abbreviated, time: .shortened)).") + } else { + Text("\"\(conflict.fileURL.lastPathComponent)\" changed on disk.") + } + } else { + Text("The file changed on disk while you had unsaved edits.") + } + } + .sheet(isPresented: contentView.$showExternalConflictCompareSheet, onDismiss: { + contentView.externalConflictCompareSnapshot = nil + }) { + if let snapshot = contentView.externalConflictCompareSnapshot { + NavigationStack { + VStack(spacing: 12) { + Text("Compare Local vs Disk: \(snapshot.fileName)") + .font(.headline) + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 6) { + Text("Local") + .font(.subheadline.weight(.semibold)) + TextEditor(text: .constant(snapshot.localContent)) + .font(.system(.footnote, design: .monospaced)) + .disabled(true) + } + VStack(alignment: .leading, spacing: 6) { + Text("Disk") + .font(.subheadline.weight(.semibold)) + TextEditor(text: .constant(snapshot.diskContent)) + .font(.system(.footnote, design: .monospaced)) + .disabled(true) + } + } + .frame(maxHeight: .infinity) + HStack { + Button("Use Disk", role: .destructive) { + if let conflict = contentView.viewModel.pendingExternalFileConflict { + contentView.viewModel.resolveExternalConflictByReloadingDisk(tabID: conflict.tabID) + } + contentView.showExternalConflictCompareSheet = false + } + Spacer() + Button("Keep Local and Save") { + if let conflict = contentView.viewModel.pendingExternalFileConflict { + contentView.viewModel.resolveExternalConflictByKeepingLocal(tabID: conflict.tabID) + } + contentView.showExternalConflictCompareSheet = false + } + } + } + .padding(16) + .navigationTitle("External Change") + } + } + } .confirmationDialog("Clear editor content?", isPresented: contentView.$showClearEditorConfirmDialog, titleVisibility: .visible) { Button("Clear", role: .destructive) { contentView.clearEditorContent() } Button("Cancel", role: .cancel) {} @@ -2195,6 +2315,8 @@ struct ContentView: View { } #endif + restoreLastSessionViewContextIfAvailable() + restoreCaretForSelectedSessionFileIfAvailable() didApplyStartupBehavior = true persistSessionIfReady() } @@ -2204,6 +2326,7 @@ struct ContentView: View { let fileURLs = viewModel.tabs.compactMap { $0.fileURL } UserDefaults.standard.set(fileURLs.map(\.absoluteString), forKey: "LastSessionFileURLs") UserDefaults.standard.set(viewModel.selectedTab?.fileURL?.absoluteString, forKey: "LastSessionSelectedFileURL") + persistLastSessionViewContext() persistLastSessionProjectFolderURL(projectRootFolderURL) #if os(iOS) persistLastSessionSecurityScopedBookmarks(fileURLs: fileURLs, selectedURL: viewModel.selectedTab?.fileURL) @@ -2269,8 +2392,59 @@ struct ContentView: View { return nil } + private var lastSessionShowSidebarKey: String { "LastSessionShowSidebarV1" } + private var lastSessionShowProjectSidebarKey: String { "LastSessionShowProjectSidebarV1" } + private var lastSessionShowMarkdownPreviewKey: String { "LastSessionShowMarkdownPreviewV1" } + private var lastSessionCaretByFileURLKey: String { "LastSessionCaretByFileURLV1" } + private var lastSessionProjectFolderURLKey: String { "LastSessionProjectFolderURL" } + private func persistLastSessionViewContext() { + let defaults = UserDefaults.standard + defaults.set(viewModel.showSidebar, forKey: lastSessionShowSidebarKey) + defaults.set(showProjectStructureSidebar, forKey: lastSessionShowProjectSidebarKey) + defaults.set(showMarkdownPreviewPane, forKey: lastSessionShowMarkdownPreviewKey) + + if let selectedURL = viewModel.selectedTab?.fileURL { + let key = selectedURL.standardizedFileURL.absoluteString + if !key.isEmpty { + sessionCaretByFileURL[key] = max(0, lastCaretLocation) + } + } + defaults.set(sessionCaretByFileURL, forKey: lastSessionCaretByFileURLKey) + } + + private func restoreLastSessionViewContextIfAvailable() { + let defaults = UserDefaults.standard + if defaults.object(forKey: lastSessionShowSidebarKey) != nil { + viewModel.showSidebar = defaults.bool(forKey: lastSessionShowSidebarKey) + } + if defaults.object(forKey: lastSessionShowProjectSidebarKey) != nil { + showProjectStructureSidebar = defaults.bool(forKey: lastSessionShowProjectSidebarKey) + } + if defaults.object(forKey: lastSessionShowMarkdownPreviewKey) != nil { + showMarkdownPreviewPane = defaults.bool(forKey: lastSessionShowMarkdownPreviewKey) + } + sessionCaretByFileURL = defaults.dictionary(forKey: lastSessionCaretByFileURLKey) as? [String: Int] ?? [:] + } + + private func restoreCaretForSelectedSessionFileIfAvailable() { + guard let selectedURL = viewModel.selectedTab?.fileURL?.standardizedFileURL else { return } + guard let location = sessionCaretByFileURL[selectedURL.absoluteString], location >= 0 else { return } + var userInfo: [String: Any] = [ + EditorCommandUserInfo.rangeLocation: location, + EditorCommandUserInfo.rangeLength: 0 + ] +#if os(macOS) + if let hostWindowNumber { + userInfo[EditorCommandUserInfo.windowNumber] = hostWindowNumber + } +#endif + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + NotificationCenter.default.post(name: .moveCursorToRange, object: nil, userInfo: userInfo) + } + } + private func persistLastSessionProjectFolderURL(_ folderURL: URL?) { guard let folderURL else { UserDefaults.standard.removeObject(forKey: lastSessionProjectFolderURLKey) @@ -2476,7 +2650,8 @@ struct ContentView: View { fileURL: saved.fileURLString.flatMap(URL.init(string:)), languageLocked: true, isDirty: true, - lastSavedFingerprint: nil + lastSavedFingerprint: nil, + lastKnownFileModificationDate: nil ) } viewModel.restoreTabsFromSnapshot(restoredTabs, selectedIndex: nil) diff --git a/Neon Vision Editor/UI/EditorTextView.swift b/Neon Vision Editor/UI/EditorTextView.swift index b31e901..eb0a8fd 100644 --- a/Neon Vision Editor/UI/EditorTextView.swift +++ b/Neon Vision Editor/UI/EditorTextView.swift @@ -2819,7 +2819,7 @@ struct CustomTextEditor: NSViewRepresentable { NotificationCenter.default.post( name: .caretPositionDidChange, object: nil, - userInfo: ["line": 0, "column": location] + userInfo: ["line": 0, "column": location, "location": location] ) return } @@ -2832,7 +2832,11 @@ struct CustomTextEditor: NSViewRepresentable { return prefix.count } }() - NotificationCenter.default.post(name: .caretPositionDidChange, object: nil, userInfo: ["line": line, "column": col]) + NotificationCenter.default.post( + name: .caretPositionDidChange, + object: nil, + userInfo: ["line": line, "column": col, "location": location] + ) if triggerHighlight { // For very large files, avoid immediate full caret-triggered passes to keep UI responsive. let immediateHighlight = ns.length < 200_000 diff --git a/Neon Vision Editor/UI/NeonSettingsView.swift b/Neon Vision Editor/UI/NeonSettingsView.swift index d4169b5..5f8f803 100644 --- a/Neon Vision Editor/UI/NeonSettingsView.swift +++ b/Neon Vision Editor/UI/NeonSettingsView.swift @@ -5,6 +5,9 @@ import AppKit #if canImport(CoreText) import CoreText #endif +#if canImport(UIKit) +import UIKit +#endif struct NeonSettingsView: View { private static var cachedEditorFonts: [String] = [] @@ -63,6 +66,7 @@ struct NeonSettingsView: View { @State private var showDataDisclosureDialog: Bool = false @State private var availableEditorFonts: [String] = [] @State private var moreSectionTab: String = "support" + @State private var diagnosticsCopyStatus: String = "" @State private var supportRefreshTask: Task? @State private var isDiscoveringFonts: Bool = false private let privacyPolicyURL = URL(string: "https://github.com/h3pdesign/Neon-Vision-Editor/blob/main/PRIVACY.md") @@ -1364,6 +1368,7 @@ struct NeonSettingsView: View { Picker("More Section", selection: $moreSectionTab) { Text("Support").tag("support") Text("AI").tag("ai") + Text("Diagnostics").tag("diagnostics") } .pickerStyle(.segmented) } @@ -1374,6 +1379,9 @@ struct NeonSettingsView: View { if moreSectionTab == "ai" { aiSection .transition(.opacity) + } else if moreSectionTab == "diagnostics" { + diagnosticsSection + .transition(.opacity) } else { supportSection .transition(.opacity) @@ -1655,6 +1663,121 @@ struct NeonSettingsView: View { } } + private var diagnosticsSection: some View { + let events = EditorPerformanceMonitor.shared.recentFileOpenEvents(limit: 8).reversed() + return VStack(spacing: UI.space16) { +#if os(iOS) + settingsCardSection( + title: "Diagnostics", + icon: "stethoscope", + emphasis: .secondary, + tip: "Safe local diagnostics for update and file-open troubleshooting." + ) { + diagnosticsSectionContent(events: Array(events)) + } +#else + GroupBox("Diagnostics") { + diagnosticsSectionContent(events: Array(events)) + .padding(UI.groupPadding) + } +#endif + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func diagnosticsSectionContent(events: [EditorPerformanceMonitor.FileOpenEvent]) -> some View { + VStack(alignment: .leading, spacing: UI.space10) { + Text("Updater") + .font(.subheadline.weight(.semibold)) + Text(localized("Last check result: %@", appUpdateManager.lastCheckResultSummary)) + .font(Typography.footnote) + .foregroundStyle(.secondary) + if let checkedAt = appUpdateManager.lastCheckedAt { + Text(localized("Last checked: %@", checkedAt.formatted(date: .abbreviated, time: .shortened))) + .font(Typography.footnote) + .foregroundStyle(.secondary) + } + if let pausedUntil = appUpdateManager.pausedUntil, pausedUntil > Date() { + Text(localized("Auto-check pause active until %@ (%lld consecutive failures).", pausedUntil.formatted(date: .abbreviated, time: .shortened), appUpdateManager.consecutiveFailureCount)) + .font(Typography.footnote) + .foregroundStyle(.orange) + } + + Divider() + + Text("File Open Timing") + .font(.subheadline.weight(.semibold)) + if events.isEmpty { + Text("No recent file-open snapshots yet.") + .font(Typography.footnote) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(events) { event in + HStack(spacing: 8) { + Text(event.timestamp.formatted(date: .omitted, time: .shortened)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + Text("\(event.elapsedMilliseconds) ms") + .font(.caption.monospacedDigit()) + Text(event.success ? "ok" : "fail") + .font(.caption.weight(.semibold)) + .foregroundStyle(event.success ? .green : .red) + if let bytes = event.byteCount { + Text("\(bytes) bytes") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + } + } + } + + HStack(spacing: UI.space10) { + Button("Copy Diagnostics") { + copyDiagnosticsToClipboard() + } + .buttonStyle(.borderedProminent) + if !diagnosticsCopyStatus.isEmpty { + Text(diagnosticsCopyStatus) + .font(Typography.footnote) + .foregroundStyle(.secondary) + } + } + } + } + + private var diagnosticsExportText: String { + let events = EditorPerformanceMonitor.shared.recentFileOpenEvents(limit: 12) + var lines: [String] = [] + lines.append("Neon Vision Editor Diagnostics") + lines.append("Timestamp: \(Date().formatted(date: .abbreviated, time: .shortened))") + lines.append("Updater.lastCheckResult: \(appUpdateManager.lastCheckResultSummary)") + lines.append("Updater.lastCheckedAt: \(appUpdateManager.lastCheckedAt?.formatted(date: .abbreviated, time: .shortened) ?? "never")") + if let pausedUntil = appUpdateManager.pausedUntil, pausedUntil > Date() { + lines.append("Updater.pauseUntil: \(pausedUntil.formatted(date: .abbreviated, time: .shortened))") + } + lines.append("Updater.consecutiveFailures: \(appUpdateManager.consecutiveFailureCount)") + lines.append("FileOpenEvents.count: \(events.count)") + for event in events { + lines.append( + "- \(event.timestamp.formatted(date: .omitted, time: .shortened)) | \(event.elapsedMilliseconds) ms | \(event.success ? "ok" : "fail") | bytes=\(event.byteCount.map(String.init) ?? "n/a")" + ) + } + return lines.joined(separator: "\n") + } + + private func copyDiagnosticsToClipboard() { + let text = diagnosticsExportText +#if os(macOS) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) +#elseif canImport(UIKit) + UIPasteboard.general.string = text +#endif + diagnosticsCopyStatus = "Copied" + } + #if os(macOS) private var updatesTab: some View { settingsContainer(maxWidth: 620) { diff --git a/samples/markdown-fixtures/claude-structured-output-1.md b/samples/markdown-fixtures/claude-structured-output-1.md new file mode 100644 index 0000000..2639d19 --- /dev/null +++ b/samples/markdown-fixtures/claude-structured-output-1.md @@ -0,0 +1,43 @@ +# Claude Export Fixture 1 + +## Overview + +This fixture mixes prose, lists, code fences, inline code, table rows, and emphasis markers like _snake_case_ and `token_name`. + +- Step 1: Parse data from `input.json` +- Step 2: Validate using **strict mode** +- Step 3: Emit report for `2026-03-08` + +### Nested Structure + +> Note: This section intentionally includes repeated symbols and underscore-heavy text. + +1. Item one with `inline_code()` and _italic text_. +2. Item two with `foo_bar_baz` and **bold text**. +3. Item three with [link text](https://example.com/docs?q=alpha_beta). + +```swift +struct ReportRow { + let id: String + let score: Double +} + +func render(rows: [ReportRow]) -> String { + rows.map { "\($0.id): \($0.score)" }.joined(separator: "\n") +} +``` + +```json +{ + "section": "analysis", + "items": ["alpha_beta", "gamma_delta"], + "ok": true +} +``` + +| key | value | +| --- | ----- | +| user_id | abcd_1234 | +| run_id | run_2026_03_08 | + +End of fixture. diff --git a/samples/markdown-fixtures/claude-structured-output-2.md b/samples/markdown-fixtures/claude-structured-output-2.md new file mode 100644 index 0000000..f368f10 --- /dev/null +++ b/samples/markdown-fixtures/claude-structured-output-2.md @@ -0,0 +1,36 @@ +# Claude Export Fixture 2 + +## Mixed Narrative and Snippets + +The next block contains markdown-like content that should remain stable while scrolling and editing. + +### Checklist + +- [x] Keep headings stable +- [x] Keep code fences stable +- [x] Keep list indentation stable +- [ ] Ensure no invisible mutation on save + +#### Pseudo transcript + +User: "Can you generate a deployment plan?" +Assistant: "Yes, here is a plan with phases." + +~~~bash +set -euo pipefail +for file in *.md; do + echo "checking ${file}" + rg -n "TODO|FIXME" "$file" || true +done +~~~ + +### Stress text + +Words_with_underscores should not be interpreted as markdown emphasis when they are plain identifiers. + +`a_b_c` `x_y_z` __double__ **strong** *single* _single_ + +> Block quote line 1 +> Block quote line 2 + +Final paragraph with punctuation: (alpha), [beta], {gamma}, .