Implement 0.5.1 milestone: markdown stability, session context, conflict workflow, diagnostics

This commit is contained in:
h3p 2026-03-08 13:49:48 +01:00
parent 62b0d70189
commit f7fe16dfa5
9 changed files with 542 additions and 17 deletions

View file

@ -361,7 +361,7 @@
CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_ALLOWED = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 430; CURRENT_PROJECT_VERSION = 431;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U; DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
@ -444,7 +444,7 @@
CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_ALLOWED = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 430; CURRENT_PROJECT_VERSION = 431;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U; DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;

View file

@ -3,6 +3,14 @@ import OSLog
@MainActor @MainActor
final class EditorPerformanceMonitor { 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() static let shared = EditorPerformanceMonitor()
private let logger = Logger(subsystem: "h3p.Neon-Vision-Editor", category: "Performance") private let logger = Logger(subsystem: "h3p.Neon-Vision-Editor", category: "Performance")
@ -10,6 +18,9 @@ final class EditorPerformanceMonitor {
private var didLogFirstPaint = false private var didLogFirstPaint = false
private var didLogFirstKeystroke = false private var didLogFirstKeystroke = false
private var fileOpenStartUptimeByTabID: [UUID: TimeInterval] = [:] private var fileOpenStartUptimeByTabID: [UUID: TimeInterval] = [:]
private let defaults = UserDefaults.standard
private let eventsDefaultsKey = "PerformanceRecentFileOpenEventsV1"
private let maxEvents = 30
private init() {} private init() {}
@ -41,8 +52,17 @@ final class EditorPerformanceMonitor {
func endFileOpen(tabID: UUID, success: Bool, byteCount: Int?) { func endFileOpen(tabID: UUID, success: Bool, byteCount: Int?) {
guard let startedAt = fileOpenStartUptimeByTabID.removeValue(forKey: tabID) else { return } guard let startedAt = fileOpenStartUptimeByTabID.removeValue(forKey: tabID) else { return }
#if DEBUG
let elapsed = Self.elapsedMilliseconds(since: startedAt) let elapsed = Self.elapsedMilliseconds(since: startedAt)
storeFileOpenEvent(
FileOpenEvent(
id: UUID(),
timestamp: Date(),
elapsedMilliseconds: elapsed,
success: success,
byteCount: byteCount
)
)
#if DEBUG
if let byteCount { if let byteCount {
logger.debug( logger.debug(
"perf.file_open_ms=\(elapsed, privacy: .public) success=\(success, privacy: .public) bytes=\(byteCount, privacy: .public)" "perf.file_open_ms=\(elapsed, privacy: .public) success=\(success, privacy: .public) bytes=\(byteCount, privacy: .public)"
@ -53,6 +73,25 @@ final class EditorPerformanceMonitor {
#endif #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 { private static func elapsedMilliseconds(since startUptime: TimeInterval) -> Int {
max(0, Int((ProcessInfo.processInfo.systemUptime - startUptime) * 1_000)) max(0, Int((ProcessInfo.processInfo.systemUptime - startUptime) * 1_000))
} }

View file

@ -236,11 +236,11 @@ func getSyntaxPatterns(
return [ return [
#"(?m)^\s{0,3}#{1,6}\s+.*$"#: colors.meta, #"(?m)^\s{0,3}#{1,6}\s+.*$"#: colors.meta,
#"(?m)^\s{0,3}(=+|-+)\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)^```[A-Za-z0-9_-]*\s*$|(?m)^~~~[A-Za-z0-9_-]*\s*$"#: colors.keyword,
#"(?m)^\s*[-*+]\s+.*$|(?m)^\s*\d+\.\s+.*$"#: colors.keyword, #"(?m)^\s*[-*+]\s+.*$|(?m)^\s*\d+\.\s+.*$"#: colors.keyword,
#"\*\*[^*\n]+\*\*|__[^_\n]+__"#: colors.def, #"\*\*[^*\n]+\*\*|__[^_\n]+__"#: colors.def,
#"(?<!_)_[^_\n]+_(?!_)|(?<!\*)\*[^*\n]+\*(?!\*)"#: colors.def, #"(?<![\w_])_(?!_)[^_\n]+_(?![\w_])|(?<![\w*])\*(?!\*)[^*\n]+\*(?![\w*])"#: colors.def,
#"\[[^\]]+\]\([^)]+\)"#: colors.string, #"\[[^\]]+\]\([^)]+\)"#: colors.string,
#"(?m)^>\s+.*$"#: colors.comment #"(?m)^>\s+.*$"#: colors.comment
] ]

View file

@ -118,6 +118,7 @@ private struct EditorFileLoadResult: Sendable {
let detectedLanguage: String let detectedLanguage: String
let languageLocked: Bool let languageLocked: Bool
let fingerprint: UInt64? let fingerprint: UInt64?
let fileModificationDate: Date?
let isLargeCandidate: Bool let isLargeCandidate: Bool
} }
@ -309,6 +310,7 @@ final class TabData: Identifiable {
fileprivate(set) var languageLocked: Bool fileprivate(set) var languageLocked: Bool
fileprivate(set) var isDirty: Bool fileprivate(set) var isDirty: Bool
fileprivate(set) var lastSavedFingerprint: UInt64? fileprivate(set) var lastSavedFingerprint: UInt64?
fileprivate(set) var lastKnownFileModificationDate: Date?
fileprivate(set) var isLoadingContent: Bool fileprivate(set) var isLoadingContent: Bool
fileprivate(set) var isLargeFileCandidate: Bool fileprivate(set) var isLargeFileCandidate: Bool
@ -321,6 +323,7 @@ final class TabData: Identifiable {
languageLocked: Bool = false, languageLocked: Bool = false,
isDirty: Bool = false, isDirty: Bool = false,
lastSavedFingerprint: UInt64? = nil, lastSavedFingerprint: UInt64? = nil,
lastKnownFileModificationDate: Date? = nil,
isLoadingContent: Bool = false, isLoadingContent: Bool = false,
isLargeFileCandidate: Bool = false isLargeFileCandidate: Bool = false
) { ) {
@ -332,6 +335,7 @@ final class TabData: Identifiable {
self.languageLocked = languageLocked self.languageLocked = languageLocked
self.isDirty = isDirty self.isDirty = isDirty
self.lastSavedFingerprint = lastSavedFingerprint self.lastSavedFingerprint = lastSavedFingerprint
self.lastKnownFileModificationDate = lastKnownFileModificationDate
self.isLoadingContent = isLoadingContent self.isLoadingContent = isLoadingContent
self.isLargeFileCandidate = isLargeFileCandidate self.isLargeFileCandidate = isLargeFileCandidate
} }
@ -383,6 +387,10 @@ final class TabData: Identifiable {
lastSavedFingerprint = fingerprint lastSavedFingerprint = fingerprint
} }
func updateLastKnownFileModificationDate(_ date: Date?) {
lastKnownFileModificationDate = date
}
func resetContentRevision() { func resetContentRevision() {
contentRevision = 0 contentRevision = 0
} }
@ -393,6 +401,17 @@ final class TabData: Identifiable {
@MainActor @MainActor
@Observable @Observable
class EditorViewModel { 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 actor TabCommandQueue {
private var isLocked = false private var isLocked = false
private var waiters: [CheckedContinuation<Void, Never>] = [] private var waiters: [CheckedContinuation<Void, Never>] = []
@ -424,6 +443,7 @@ class EditorViewModel {
private static let deferredLanguageDetectionSampleUTF16Length = 180_000 private static let deferredLanguageDetectionSampleUTF16Length = 180_000
private(set) var tabs: [TabData] = [] private(set) var tabs: [TabData] = []
private(set) var selectedTabID: UUID? private(set) var selectedTabID: UUID?
var pendingExternalFileConflict: ExternalFileConflictState?
var showSidebar: Bool = true var showSidebar: Bool = true
var isBrainDumpMode: Bool = false var isBrainDumpMode: Bool = false
var showingRename: Bool = false var showingRename: Bool = false
@ -494,11 +514,12 @@ class EditorViewModel {
let languageLocked: Bool let languageLocked: Bool
let isDirty: Bool let isDirty: Bool
let lastSavedFingerprint: UInt64? let lastSavedFingerprint: UInt64?
let lastKnownFileModificationDate: Date?
} }
private enum TabCommand: Sendable { private enum TabCommand: Sendable {
case updateContent(tabID: UUID, mutation: TabContentMutation) 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 setLanguage(tabID: UUID, language: String, lock: Bool)
case closeTab(tabID: UUID) case closeTab(tabID: UUID)
case addNewTab(name: String, language: String) case addNewTab(name: String, language: String)
@ -523,6 +544,7 @@ class EditorViewModel {
language: String, language: String,
languageLocked: Bool, languageLocked: Bool,
fingerprint: UInt64?, fingerprint: UInt64?,
fileModificationDate: Date?,
isLargeCandidate: Bool isLargeCandidate: Bool
) )
} }
@ -553,7 +575,7 @@ class EditorViewModel {
} }
return outcome return outcome
case let .markSaved(tabID, fileURL, fingerprint): case let .markSaved(tabID, fileURL, fingerprint, fileModificationDate):
guard let index = tabIndex(for: tabID) else { return TabCommandOutcome() } guard let index = tabIndex(for: tabID) else { return TabCommandOutcome() }
let outcome = TabCommandOutcome(index: index) let outcome = TabCommandOutcome(index: index)
if let fileURL { if let fileURL {
@ -566,6 +588,7 @@ class EditorViewModel {
} }
} }
tabs[index].markClean(withFingerprint: fingerprint) tabs[index].markClean(withFingerprint: fingerprint)
tabs[index].updateLastKnownFileModificationDate(fileModificationDate)
recordTabStateMutation(rebuildIndexes: true) recordTabStateMutation(rebuildIndexes: true)
return outcome return outcome
@ -662,7 +685,8 @@ class EditorViewModel {
fileURL: snapshot.fileURL, fileURL: snapshot.fileURL,
languageLocked: snapshot.languageLocked, languageLocked: snapshot.languageLocked,
isDirty: snapshot.isDirty, isDirty: snapshot.isDirty,
lastSavedFingerprint: snapshot.lastSavedFingerprint lastSavedFingerprint: snapshot.lastSavedFingerprint,
lastKnownFileModificationDate: snapshot.lastKnownFileModificationDate
) )
) )
} }
@ -710,11 +734,12 @@ class EditorViewModel {
recordTabStateMutation() recordTabStateMutation()
return TabCommandOutcome(index: index) 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() } guard let index = tabIndex(for: tabID) else { return TabCommandOutcome() }
tabs[index].language = language tabs[index].language = language
tabs[index].languageLocked = languageLocked tabs[index].languageLocked = languageLocked
tabs[index].markClean(withFingerprint: fingerprint) tabs[index].markClean(withFingerprint: fingerprint)
tabs[index].updateLastKnownFileModificationDate(fileModificationDate)
tabs[index].isLargeFileCandidate = isLargeCandidate tabs[index].isLargeFileCandidate = isLargeCandidate
let didChange = tabs[index].replaceContentStorage( let didChange = tabs[index].replaceContentStorage(
with: content, with: content,
@ -958,8 +983,13 @@ class EditorViewModel {
} }
// Saves tab content to the existing file URL or falls back to Save As. // 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 } 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 { if let url = tabs[index].fileURL {
enqueueSave(tabID: tabID, to: url, updateFileURLOnSuccess: nil, signpostName: "save_file") enqueueSave(tabID: tabID, to: url, updateFileURLOnSuccess: nil, signpostName: "save_file")
} else { } else {
@ -971,6 +1001,55 @@ class EditorViewModel {
saveFile(tabID: tab.id) 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. // Saves tab content to a user-selected path on macOS.
func saveFileAs(tabID: UUID) { func saveFileAs(tabID: UUID) {
guard let index = tabIndex(for: tabID) else { return } guard let index = tabIndex(for: tabID) else { return }
@ -1038,13 +1117,16 @@ class EditorViewModel {
FileManager.default.fileExists(atPath: destinationURL.path) { FileManager.default.fileExists(atPath: destinationURL.path) {
if let finalIndex = self.tabIndex(for: tabID), if let finalIndex = self.tabIndex(for: tabID),
self.tabs[finalIndex].contentRevision == expectedRevision { self.tabs[finalIndex].contentRevision == expectedRevision {
let fileModificationDate = try? destinationURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
_ = self.applyTabCommand( _ = self.applyTabCommand(
.markSaved( .markSaved(
tabID: tabID, tabID: tabID,
fileURL: updateFileURLOnSuccess, fileURL: updateFileURLOnSuccess,
fingerprint: payload.fingerprint fingerprint: payload.fingerprint,
fileModificationDate: fileModificationDate
) )
) )
self.pendingExternalFileConflict = nil
} }
return return
} }
@ -1061,16 +1143,31 @@ class EditorViewModel {
return return
} }
let fileModificationDate = try? destinationURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
_ = self.applyTabCommand( _ = self.applyTabCommand(
.markSaved( .markSaved(
tabID: tabID, tabID: tabID,
fileURL: updateFileURLOnSuccess, 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. // Opens file-picker UI on macOS.
func openFile() { func openFile() {
#if os(macOS) #if os(macOS)
@ -1146,6 +1243,7 @@ class EditorViewModel {
url.stopAccessingSecurityScopedResource() url.stopAccessingSecurityScopedResource()
} }
} }
let initialModificationDate = try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
let data: Data let data: Data
if isLargeCandidate { if isLargeCandidate {
@ -1175,6 +1273,7 @@ class EditorViewModel {
detectedLanguage: detectedLanguage, detectedLanguage: detectedLanguage,
languageLocked: extLangHint != nil, languageLocked: extLangHint != nil,
fingerprint: fingerprint, fingerprint: fingerprint,
fileModificationDate: initialModificationDate,
isLargeCandidate: data.count >= EditorLoadHelper.largeFileCandidateByteThreshold isLargeCandidate: data.count >= EditorLoadHelper.largeFileCandidateByteThreshold
) )
}.value }.value
@ -1182,7 +1281,11 @@ class EditorViewModel {
private nonisolated static func prepareSavePayload(from content: String) async -> EditorFileSavePayload { private nonisolated static func prepareSavePayload(from content: String) async -> EditorFileSavePayload {
await Task.detached(priority: .userInitiated) { 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( return EditorFileSavePayload(
content: clean, content: clean,
fingerprint: Self.contentFingerprintValue(clean) fingerprint: Self.contentFingerprintValue(clean)
@ -1209,6 +1312,7 @@ class EditorViewModel {
language: result.detectedLanguage, language: result.detectedLanguage,
languageLocked: result.languageLocked, languageLocked: result.languageLocked,
fingerprint: result.fingerprint, fingerprint: result.fingerprint,
fileModificationDate: result.fileModificationDate,
isLargeCandidate: result.isLargeCandidate isLargeCandidate: result.isLargeCandidate
) )
) )
@ -1430,7 +1534,8 @@ class EditorViewModel {
.markSaved( .markSaved(
tabID: tabID, tabID: tabID,
fileURL: fileURL, fileURL: fileURL,
fingerprint: contentFingerprint(tabs[index].content) fingerprint: contentFingerprint(tabs[index].content),
fileModificationDate: fileURL.flatMap { try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate }
) )
) )
} }

View file

@ -233,6 +233,9 @@ struct ContentView: View {
@State var projectFolderSecurityURL: URL? = nil @State var projectFolderSecurityURL: URL? = nil
@State var pendingCloseTabID: UUID? = nil @State var pendingCloseTabID: UUID? = nil
@State var showUnsavedCloseDialog: Bool = false @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 showClearEditorConfirmDialog: Bool = false
@State var showIOSFileImporter: Bool = false @State var showIOSFileImporter: Bool = false
@State var showIOSFileExporter: Bool = false @State var showIOSFileExporter: Bool = false
@ -289,6 +292,8 @@ struct ContentView: View {
@State private var didRunInitialWindowLayoutSetup: Bool = false @State private var didRunInitialWindowLayoutSetup: Bool = false
@State private var pendingLargeFileModeReevaluation: DispatchWorkItem? = nil @State private var pendingLargeFileModeReevaluation: DispatchWorkItem? = nil
@State private var recoverySnapshotIdentifier: String = UUID().uuidString @State private var recoverySnapshotIdentifier: String = UUID().uuidString
@State private var lastCaretLocation: Int = 0
@State private var sessionCaretByFileURL: [String: Int] = [:]
private let quickSwitcherRecentsDefaultsKey = "QuickSwitcherRecentItemsV1" private let quickSwitcherRecentsDefaultsKey = "QuickSwitcherRecentItemsV1"
#if USE_FOUNDATION_MODELS && canImport(FoundationModels) #if USE_FOUNDATION_MODELS && canImport(FoundationModels)
@ -1266,6 +1271,12 @@ struct ContentView: View {
private func withBaseEditorEvents<Content: View>(_ view: Content) -> some View { private func withBaseEditorEvents<Content: View>(_ view: Content) -> some View {
let viewWithClipboardEvents = view let viewWithClipboardEvents = view
.onReceive(NotificationCenter.default.publisher(for: .caretPositionDidChange)) { notif in .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 let line = notif.userInfo?["line"] as? Int, let col = notif.userInfo?["column"] as? Int {
if line <= 0 { if line <= 0 {
caretStatus = "Pos \(col)" caretStatus = "Pos \(col)"
@ -1345,6 +1356,11 @@ struct ContentView: View {
updateLargeFileModeForCurrentContext() updateLargeFileModeForCurrentContext()
scheduleLargeFileModeReevaluation(after: 0.9) scheduleLargeFileModeReevaluation(after: 0.9)
scheduleHighlightRefresh() scheduleHighlightRefresh()
if let selectedID = viewModel.selectedTab?.id {
viewModel.refreshExternalConflictForTab(tabID: selectedID)
}
restoreCaretForSelectedSessionFileIfAvailable()
persistSessionIfReady()
} }
.onChange(of: viewModel.selectedTab?.isLoadingContent ?? false) { _, isLoading in .onChange(of: viewModel.selectedTab?.isLoadingContent ?? false) { _, isLoading in
if isLoading { if isLoading {
@ -1359,6 +1375,11 @@ struct ContentView: View {
.onChange(of: currentLanguage) { _, newValue in .onChange(of: currentLanguage) { _, newValue in
settingsTemplateLanguage = newValue settingsTemplateLanguage = newValue
} }
.onChange(of: viewModel.pendingExternalFileConflict?.tabID) { _, conflictTabID in
if conflictTabID != nil {
showExternalConflictDialog = true
}
}
} }
private func handlePastedTextNotification(_ notif: Notification) { private func handlePastedTextNotification(_ notif: Notification) {
@ -1809,6 +1830,28 @@ struct ContentView: View {
persistSessionIfReady() persistSessionIfReady()
persistUnsavedDraftSnapshotIfNeeded() 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 .onOpenURL { url in
viewModel.openFile(url: url) viewModel.openFile(url: url)
} }
@ -2081,6 +2124,83 @@ struct ContentView: View {
Text("This file has unsaved changes.") 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) { .confirmationDialog("Clear editor content?", isPresented: contentView.$showClearEditorConfirmDialog, titleVisibility: .visible) {
Button("Clear", role: .destructive) { contentView.clearEditorContent() } Button("Clear", role: .destructive) { contentView.clearEditorContent() }
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
@ -2195,6 +2315,8 @@ struct ContentView: View {
} }
#endif #endif
restoreLastSessionViewContextIfAvailable()
restoreCaretForSelectedSessionFileIfAvailable()
didApplyStartupBehavior = true didApplyStartupBehavior = true
persistSessionIfReady() persistSessionIfReady()
} }
@ -2204,6 +2326,7 @@ struct ContentView: View {
let fileURLs = viewModel.tabs.compactMap { $0.fileURL } let fileURLs = viewModel.tabs.compactMap { $0.fileURL }
UserDefaults.standard.set(fileURLs.map(\.absoluteString), forKey: "LastSessionFileURLs") UserDefaults.standard.set(fileURLs.map(\.absoluteString), forKey: "LastSessionFileURLs")
UserDefaults.standard.set(viewModel.selectedTab?.fileURL?.absoluteString, forKey: "LastSessionSelectedFileURL") UserDefaults.standard.set(viewModel.selectedTab?.fileURL?.absoluteString, forKey: "LastSessionSelectedFileURL")
persistLastSessionViewContext()
persistLastSessionProjectFolderURL(projectRootFolderURL) persistLastSessionProjectFolderURL(projectRootFolderURL)
#if os(iOS) #if os(iOS)
persistLastSessionSecurityScopedBookmarks(fileURLs: fileURLs, selectedURL: viewModel.selectedTab?.fileURL) persistLastSessionSecurityScopedBookmarks(fileURLs: fileURLs, selectedURL: viewModel.selectedTab?.fileURL)
@ -2269,8 +2392,59 @@ struct ContentView: View {
return nil 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 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?) { private func persistLastSessionProjectFolderURL(_ folderURL: URL?) {
guard let folderURL else { guard let folderURL else {
UserDefaults.standard.removeObject(forKey: lastSessionProjectFolderURLKey) UserDefaults.standard.removeObject(forKey: lastSessionProjectFolderURLKey)
@ -2476,7 +2650,8 @@ struct ContentView: View {
fileURL: saved.fileURLString.flatMap(URL.init(string:)), fileURL: saved.fileURLString.flatMap(URL.init(string:)),
languageLocked: true, languageLocked: true,
isDirty: true, isDirty: true,
lastSavedFingerprint: nil lastSavedFingerprint: nil,
lastKnownFileModificationDate: nil
) )
} }
viewModel.restoreTabsFromSnapshot(restoredTabs, selectedIndex: nil) viewModel.restoreTabsFromSnapshot(restoredTabs, selectedIndex: nil)

View file

@ -2819,7 +2819,7 @@ struct CustomTextEditor: NSViewRepresentable {
NotificationCenter.default.post( NotificationCenter.default.post(
name: .caretPositionDidChange, name: .caretPositionDidChange,
object: nil, object: nil,
userInfo: ["line": 0, "column": location] userInfo: ["line": 0, "column": location, "location": location]
) )
return return
} }
@ -2832,7 +2832,11 @@ struct CustomTextEditor: NSViewRepresentable {
return prefix.count 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 { if triggerHighlight {
// For very large files, avoid immediate full caret-triggered passes to keep UI responsive. // For very large files, avoid immediate full caret-triggered passes to keep UI responsive.
let immediateHighlight = ns.length < 200_000 let immediateHighlight = ns.length < 200_000

View file

@ -5,6 +5,9 @@ import AppKit
#if canImport(CoreText) #if canImport(CoreText)
import CoreText import CoreText
#endif #endif
#if canImport(UIKit)
import UIKit
#endif
struct NeonSettingsView: View { struct NeonSettingsView: View {
private static var cachedEditorFonts: [String] = [] private static var cachedEditorFonts: [String] = []
@ -63,6 +66,7 @@ struct NeonSettingsView: View {
@State private var showDataDisclosureDialog: Bool = false @State private var showDataDisclosureDialog: Bool = false
@State private var availableEditorFonts: [String] = [] @State private var availableEditorFonts: [String] = []
@State private var moreSectionTab: String = "support" @State private var moreSectionTab: String = "support"
@State private var diagnosticsCopyStatus: String = ""
@State private var supportRefreshTask: Task<Void, Never>? @State private var supportRefreshTask: Task<Void, Never>?
@State private var isDiscoveringFonts: Bool = false @State private var isDiscoveringFonts: Bool = false
private let privacyPolicyURL = URL(string: "https://github.com/h3pdesign/Neon-Vision-Editor/blob/main/PRIVACY.md") 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) { Picker("More Section", selection: $moreSectionTab) {
Text("Support").tag("support") Text("Support").tag("support")
Text("AI").tag("ai") Text("AI").tag("ai")
Text("Diagnostics").tag("diagnostics")
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
} }
@ -1374,6 +1379,9 @@ struct NeonSettingsView: View {
if moreSectionTab == "ai" { if moreSectionTab == "ai" {
aiSection aiSection
.transition(.opacity) .transition(.opacity)
} else if moreSectionTab == "diagnostics" {
diagnosticsSection
.transition(.opacity)
} else { } else {
supportSection supportSection
.transition(.opacity) .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) #if os(macOS)
private var updatesTab: some View { private var updatesTab: some View {
settingsContainer(maxWidth: 620) { settingsContainer(maxWidth: 620) {

View file

@ -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.

View file

@ -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}, <delta>.