mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 13:27:16 +00:00
Implement 0.5.1 milestone: markdown stability, session context, conflict workflow, diagnostics
This commit is contained in:
parent
62b0d70189
commit
f7fe16dfa5
9 changed files with 542 additions and 17 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
#"(?<!_)_[^_\n]+_(?!_)|(?<!\*)\*[^*\n]+\*(?!\*)"#: colors.def,
|
||||
#"(?<![\w_])_(?!_)[^_\n]+_(?![\w_])|(?<![\w*])\*(?!\*)[^*\n]+\*(?![\w*])"#: colors.def,
|
||||
#"\[[^\]]+\]\([^)]+\)"#: colors.string,
|
||||
#"(?m)^>\s+.*$"#: colors.comment
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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<Void, Never>] = []
|
||||
|
|
@ -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 }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Content: View>(_ 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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Void, Never>?
|
||||
@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) {
|
||||
|
|
|
|||
43
samples/markdown-fixtures/claude-structured-output-1.md
Normal file
43
samples/markdown-fixtures/claude-structured-output-1.md
Normal 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.
|
||||
36
samples/markdown-fixtures/claude-structured-output-2.md
Normal file
36
samples/markdown-fixtures/claude-structured-output-2.md
Normal 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>.
|
||||
Loading…
Reference in a new issue