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_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;
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
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