v0.5.5: fix session restore, sidebar first-open highlighting, and large-file gating

This commit is contained in:
h3p 2026-03-16 11:20:48 +01:00
parent 6e8e756a83
commit 1d99fdf5c8
6 changed files with 159 additions and 44 deletions

View file

@ -4,6 +4,27 @@ All notable changes to **Neon Vision Editor** are documented in this file.
The format follows *Keep a Changelog*. Versions use semantic versioning with prerelease tags.
## [v0.5.5] - 2026-03-16
### Highlights
- Stabilized first-open rendering from the project sidebar so file content and syntax highlighting appear on first click without requiring tab switches.
- Hardened startup/session behavior so `Reopen Last Session` reliably wins over conflicting blank-document startup states.
- Refined large-file activation and loading placeholders to avoid misclassifying smaller files as large-file sessions.
### Fixes
- Fixed a session-restore regression where previously open files could appear empty on first sidebar click until changing tabs.
- Fixed highlight scheduling during document-state transitions (`switch`, `finish load`, external edits) on macOS, iOS, and iPadOS.
- Fixed startup-default conflicts by aligning defaults and runtime startup gating between `Reopen Last Session` and `Open with Blank Document`.
- Fixed macOS shutdown persistence timing by saving session/draft snapshots on `willResignActive` and `willTerminate`.
- Fixed line-number ruler refresh timing to reduce layout churn/flicker and avoid draw-time retile side effects.
- Fixed horizontal viewport carry-over during document transitions so left-edge content no longer opens clipped.
### Breaking changes
- None.
### Migration
- None.
## [v0.5.4] - 2026-03-13
### Hero Screenshot

View file

@ -361,7 +361,7 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 523;
CURRENT_PROJECT_VERSION = 524;
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 = 523;
CURRENT_PROJECT_VERSION = 524;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES;

View file

@ -206,7 +206,7 @@ struct NeonVisionEditorApp: App {
"SettingsCompletionFromDocument": false,
"SettingsCompletionFromSyntax": false,
"SettingsReopenLastSession": true,
"SettingsOpenWithBlankDocument": true,
"SettingsOpenWithBlankDocument": false,
"SettingsDefaultNewFileLanguage": "plain",
"SettingsConfirmCloseDirtyTab": true,
"SettingsConfirmClearEditor": true,

View file

@ -1494,7 +1494,11 @@ struct ContentView: View {
}
.onChange(of: viewModel.selectedTab?.isLoadingContent ?? false) { _, isLoading in
if isLoading {
if !largeFileModeEnabled {
let shouldPreEnableLargeMode =
droppedFileLoadInProgress ||
viewModel.selectedTab?.isLargeFileCandidate == true ||
currentDocumentUTF16Length >= 300_000
if shouldPreEnableLargeMode, !largeFileModeEnabled {
largeFileModeEnabled = true
}
} else {
@ -1570,13 +1574,21 @@ struct ContentView: View {
}
func updateLargeFileMode(for text: String) {
if droppedFileLoadInProgress || viewModel.selectedTab?.isLoadingContent == true {
if droppedFileLoadInProgress {
if !largeFileModeEnabled {
largeFileModeEnabled = true
scheduleHighlightRefresh()
}
return
}
if viewModel.selectedTab?.isLoadingContent == true {
if (viewModel.selectedTab?.isLargeFileCandidate == true || currentDocumentUTF16Length >= 300_000),
!largeFileModeEnabled {
largeFileModeEnabled = true
scheduleHighlightRefresh()
}
return
}
if viewModel.selectedTab?.isLargeFileCandidate == true {
if !largeFileModeEnabled {
largeFileModeEnabled = true
@ -1653,13 +1665,21 @@ struct ContentView: View {
}
private func updateLargeFileModeForCurrentContext() {
if droppedFileLoadInProgress || viewModel.selectedTab?.isLoadingContent == true {
if droppedFileLoadInProgress {
if !largeFileModeEnabled {
largeFileModeEnabled = true
scheduleHighlightRefresh()
}
return
}
if viewModel.selectedTab?.isLoadingContent == true {
if (viewModel.selectedTab?.isLargeFileCandidate == true || currentDocumentUTF16Length >= 300_000),
!largeFileModeEnabled {
largeFileModeEnabled = true
scheduleHighlightRefresh()
}
return
}
if viewModel.selectedTab?.isLargeFileCandidate == true || currentDocumentUTF16Length >= 300_000 {
if !largeFileModeEnabled {
largeFileModeEnabled = true
@ -2095,6 +2115,14 @@ struct ContentView: View {
viewModel.refreshExternalConflictForTab(tabID: selectedID)
}
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification)) { _ in
persistSessionIfReady()
persistUnsavedDraftSnapshotIfNeeded()
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
persistSessionIfReady()
persistUnsavedDraftSnapshotIfNeeded()
}
#endif
.onOpenURL { url in
viewModel.openFile(url: url)
@ -2573,7 +2601,9 @@ struct ContentView: View {
return
}
if openWithBlankDocument {
// If both startup toggles are enabled (legacy/default mismatch), prefer session restore.
let shouldOpenBlankOnStartup = openWithBlankDocument && !reopenLastSession
if shouldOpenBlankOnStartup {
viewModel.resetTabsForSessionRestore()
viewModel.addNewTab()
projectRootFolderURL = nil
@ -3112,7 +3142,6 @@ struct ContentView: View {
private var effectiveLargeFileModeEnabled: Bool {
if largeFileModeEnabled { return true }
if droppedFileLoadInProgress { return true }
if viewModel.selectedTab?.isLoadingContent == true { return true }
if viewModel.selectedTab?.isLargeFileCandidate == true { return true }
return currentDocumentUTF16Length >= 300_000
}
@ -3972,7 +4001,9 @@ struct ContentView: View {
delimitedTableView
} else if shouldUseDeferredLargeFileOpenMode,
viewModel.selectedTab?.isLoadingContent == true,
effectiveLargeFileModeEnabled {
(viewModel.selectedTab?.isLargeFileCandidate == true ||
currentDocumentUTF16Length >= 300_000 ||
largeFileModeEnabled) {
largeFileLoadingPlaceholder
} else {
// Single editor (no TabView)

View file

@ -137,7 +137,8 @@ private enum LargeFileInstallRuntime {
private func replaceTextPreservingSelectionAndFocus(
_ textView: NSTextView,
with newText: String,
preserveViewport: Bool = true
preserveViewport: Bool = true,
preserveHorizontalOffset: Bool = true
) {
let previousSelection = textView.selectedRange()
let hadFocus = (textView.window?.firstResponder as? NSTextView) === textView
@ -148,7 +149,15 @@ private func replaceTextPreservingSelectionAndFocus(
let safeLength = min(max(0, previousSelection.length), max(0, length - safeLocation))
textView.setSelectedRange(NSRange(location: safeLocation, length: safeLength))
if let clipView = textView.enclosingScrollView?.contentView {
let targetOrigin = preserveViewport ? priorOrigin : .zero
let targetOrigin: CGPoint
if preserveViewport {
targetOrigin = CGPoint(
x: preserveHorizontalOffset ? priorOrigin.x : 0,
y: priorOrigin.y
)
} else {
targetOrigin = .zero
}
clipView.scroll(to: targetOrigin)
textView.enclosingScrollView?.reflectScrolledClipView(clipView)
}
@ -2136,6 +2145,7 @@ struct CustomTextEditor: NSViewRepresentable {
let didSwitchDocument = context.coordinator.lastDocumentID != documentID
let didFinishTabLoad = (context.coordinator.lastTabLoadingContent == true) && !isTabLoadingContent
let didReceiveExternalEdit = context.coordinator.lastExternalEditRevision != externalEditRevision
let didTransitionDocumentState = didSwitchDocument || didFinishTabLoad || didReceiveExternalEdit
let isInteractionSuppressed = context.coordinator.isInInteractionSuppressionWindow()
if didSwitchDocument {
context.coordinator.lastDocumentID = documentID
@ -2165,20 +2175,25 @@ struct CustomTextEditor: NSViewRepresentable {
!didSwitchDocument &&
!didFinishTabLoad &&
!didReceiveExternalEdit
if shouldPreferEditorBuffer || isInteractionSuppressed {
let shouldDeferToEditorBuffer =
shouldPreferEditorBuffer ||
(!didTransitionDocumentState && isInteractionSuppressed)
if shouldDeferToEditorBuffer {
context.coordinator.syncBindingTextImmediately(textView.string)
} else {
context.coordinator.cancelPendingBindingSync()
let didInstallLargeText = context.coordinator.installLargeTextIfNeeded(
on: textView,
target: target,
preserveViewport: !didSwitchDocument
preserveViewport: !didSwitchDocument,
preserveHorizontalOffset: !didTransitionDocumentState
)
if !didInstallLargeText {
replaceTextPreservingSelectionAndFocus(
textView,
with: target,
preserveViewport: !didSwitchDocument
preserveViewport: !didSwitchDocument,
preserveHorizontalOffset: !didTransitionDocumentState
)
needsLayoutRefresh = true
}
@ -2238,7 +2253,8 @@ struct CustomTextEditor: NSViewRepresentable {
replaceTextPreservingSelectionAndFocus(
textView,
with: sanitized,
preserveViewport: !didSwitchDocument
preserveViewport: !didSwitchDocument,
preserveHorizontalOffset: !didTransitionDocumentState
)
needsLayoutRefresh = true
context.coordinator.invalidateHighlightCache()
@ -2333,27 +2349,40 @@ struct CustomTextEditor: NSViewRepresentable {
needsLayoutRefresh = true
}
if didChangeRulerConfiguration {
if showLineNumbersByDefault && didTransitionDocumentState {
if let ruler = nsView.verticalRulerView as? LineNumberRulerView {
ruler.forceRulerLayoutRefresh()
} else {
context.coordinator.scheduleDeferredRulerTile(for: nsView)
}
} else if didChangeRulerConfiguration {
context.coordinator.scheduleDeferredRulerTile(for: nsView)
}
if needsLayoutRefresh, let container = textView.textContainer {
context.coordinator.scheduleDeferredEnsureLayout(for: textView, container: container)
}
if didTransitionDocumentState {
context.coordinator.normalizeHorizontalScrollOffset(for: nsView)
}
// Only schedule highlight if needed (e.g., language/color scheme changes or external text updates)
context.coordinator.parent = self
if !isDropApplyInFlight {
let shouldSchedule = context.coordinator.shouldScheduleHighlightFromUpdate(
currentText: textView.string,
language: language,
colorScheme: colorScheme,
lineHeightValue: lineHeightMultiple,
token: highlightRefreshToken,
translucencyEnabled: translucentBackgroundEnabled
)
if shouldSchedule {
context.coordinator.scheduleHighlightIfNeeded()
if didTransitionDocumentState {
context.coordinator.scheduleHighlightIfNeeded(currentText: textView.string, immediate: true)
} else {
let shouldSchedule = context.coordinator.shouldScheduleHighlightFromUpdate(
currentText: textView.string,
language: language,
colorScheme: colorScheme,
lineHeightValue: lineHeightMultiple,
token: highlightRefreshToken,
translucencyEnabled: translucentBackgroundEnabled
)
if shouldSchedule {
context.coordinator.scheduleHighlightIfNeeded()
}
}
}
}
@ -2462,7 +2491,8 @@ struct CustomTextEditor: NSViewRepresentable {
fileprivate func installLargeTextIfNeeded(
on textView: NSTextView,
target: String,
preserveViewport: Bool
preserveViewport: Bool,
preserveHorizontalOffset: Bool = true
) -> Bool {
guard parent.isLargeFileMode else { return false }
let openMode = currentLargeFileOpenMode()
@ -2494,7 +2524,15 @@ struct CustomTextEditor: NSViewRepresentable {
let safeLength = min(max(0, previousSelection.length), max(0, targetLength - safeLocation))
textView.setSelectedRange(NSRange(location: safeLocation, length: safeLength))
if let clipView = textView.enclosingScrollView?.contentView {
let targetOrigin = preserveViewport ? priorOrigin : .zero
let targetOrigin: CGPoint
if preserveViewport {
targetOrigin = CGPoint(
x: preserveHorizontalOffset ? priorOrigin.x : 0,
y: priorOrigin.y
)
} else {
targetOrigin = .zero
}
clipView.scroll(to: targetOrigin)
textView.enclosingScrollView?.reflectScrolledClipView(clipView)
}
@ -2519,6 +2557,14 @@ struct CustomTextEditor: NSViewRepresentable {
return true
}
func normalizeHorizontalScrollOffset(for scrollView: NSScrollView) {
let clipView = scrollView.contentView
let origin = clipView.bounds.origin
guard abs(origin.x) > 0.5 else { return }
clipView.scroll(to: CGPoint(x: 0, y: origin.y))
scrollView.reflectScrolledClipView(clipView)
}
private func debugViewportTrace(_ source: String, textView: NSTextView? = nil) {
#if DEBUG
let tv = textView ?? self.textView
@ -2821,16 +2867,17 @@ struct CustomTextEditor: NSViewRepresentable {
if let explicitRange {
return explicitRange
}
// For very large buffers, prioritize visible content while typing.
guard text.length >= 100_000 else { return fullRange }
// Restrict to visible range only for responsive large-file profiles.
let supportsResponsiveRange =
parent.isLargeFileMode &&
supportsResponsiveLargeFileHighlight(language: parent.language, textLength: text.length)
guard supportsResponsiveRange, text.length >= 100_000 else { return fullRange }
guard let layoutManager = textView.layoutManager,
let textContainer = textView.textContainer else { return fullRange }
let visibleGlyphRange = layoutManager.glyphRange(forBoundingRect: textView.visibleRect, in: textContainer)
let visibleCharacterRange = layoutManager.characterRange(forGlyphRange: visibleGlyphRange, actualGlyphRange: nil)
guard visibleCharacterRange.length > 0 else { return fullRange }
let padding = (parent.isLargeFileMode && supportsResponsiveLargeFileHighlight(language: parent.language, textLength: text.length))
? EditorRuntimeLimits.largeFileJSONVisiblePaddingUTF16
: 12_000
let padding = EditorRuntimeLimits.largeFileJSONVisiblePaddingUTF16
return expandedHighlightRange(around: visibleCharacterRange, in: text, maxUTF16Padding: padding)
}
@ -3742,6 +3789,7 @@ struct CustomTextEditor: UIViewRepresentable {
let didSwitchDocument = context.coordinator.lastDocumentID != documentID
let didFinishTabLoad = (context.coordinator.lastTabLoadingContent == true) && !isTabLoadingContent
let didReceiveExternalEdit = context.coordinator.lastExternalEditRevision != externalEditRevision
let didTransitionDocumentState = didSwitchDocument || didFinishTabLoad || didReceiveExternalEdit
if didSwitchDocument {
context.coordinator.lastDocumentID = documentID
context.coordinator.cancelPendingBindingSync()
@ -3843,7 +3891,11 @@ struct CustomTextEditor: UIViewRepresentable {
uiView.updateLineNumbers(for: text, fontSize: fontSize)
}
context.coordinator.syncLineNumberScroll()
context.coordinator.scheduleHighlightIfNeeded(currentText: text)
if didTransitionDocumentState {
context.coordinator.scheduleHighlightIfNeeded(currentText: textView.text ?? text, immediate: true)
} else {
context.coordinator.scheduleHighlightIfNeeded(currentText: text)
}
}
func makeCoordinator() -> Coordinator {
@ -4217,15 +4269,16 @@ struct CustomTextEditor: UIViewRepresentable {
immediate: Bool
) -> NSRange {
let fullRange = NSRange(location: 0, length: text.length)
// Keep syntax matching focused on visible content for very large buffers.
guard text.length >= 100_000 else { return fullRange }
// Restrict to visible range only for responsive large-file profiles.
let supportsResponsiveRange =
parent.isLargeFileMode &&
supportsResponsiveLargeFileHighlight(language: parent.language, textLength: text.length)
guard supportsResponsiveRange, text.length >= 100_000 else { return fullRange }
let visibleRect = CGRect(origin: textView.contentOffset, size: textView.bounds.size).insetBy(dx: 0, dy: -80)
let glyphRange = textView.layoutManager.glyphRange(forBoundingRect: visibleRect, in: textView.textContainer)
let charRange = textView.layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)
guard charRange.length > 0 else { return fullRange }
let padding = (parent.isLargeFileMode && supportsResponsiveLargeFileHighlight(language: parent.language, textLength: text.length))
? EditorRuntimeLimits.largeFileJSONVisiblePaddingUTF16
: 12_000
let padding = EditorRuntimeLimits.largeFileJSONVisiblePaddingUTF16
return expandedRange(around: charRange, in: text, maxUTF16Padding: padding)
}

View file

@ -47,7 +47,6 @@ final class LineNumberRulerView: NSRulerView {
override func draw(_ dirtyRect: NSRect) {
rebuildLineCacheIfNeeded()
updateRuleThicknessIfNeeded()
let bg: NSColor = {
guard let tv = textView else { return .windowBackgroundColor }
@ -200,19 +199,30 @@ final class LineNumberRulerView: NSRulerView {
needsDisplay = true
}
private func updateRuleThicknessIfNeeded() {
@discardableResult
private func updateRuleThicknessIfNeeded() -> Bool {
rebuildLineCacheIfNeeded()
let lineCount = max(1, cachedLineStarts.count)
let digits = max(2, String(lineCount).count)
guard digits != cachedDigitCount else { return }
cachedDigitCount = digits
let glyphWidth = NSString(string: "8").size(withAttributes: [.font: font]).width
let targetThickness = ceil((glyphWidth * CGFloat(digits)) + (inset * 2) + 8)
cachedDigitCount = digits
if abs(ruleThickness - targetThickness) > 0.5 {
ruleThickness = targetThickness
scrollView?.tile()
return true
}
return false
}
@MainActor
func forceRulerLayoutRefresh() {
needsLineCacheRebuild = true
let didRetileFromThickness = updateRuleThicknessIfNeeded()
if !didRetileFromThickness {
scrollView?.tile()
}
needsDisplay = true
}
// Keep line-number lookup O(log n) while scrolling by caching UTF-16 line starts.