mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 13:27:16 +00:00
v0.5.5: fix session restore, sidebar first-open highlighting, and large-file gating
This commit is contained in:
parent
6e8e756a83
commit
1d99fdf5c8
6 changed files with 159 additions and 44 deletions
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ struct NeonVisionEditorApp: App {
|
|||
"SettingsCompletionFromDocument": false,
|
||||
"SettingsCompletionFromSyntax": false,
|
||||
"SettingsReopenLastSession": true,
|
||||
"SettingsOpenWithBlankDocument": true,
|
||||
"SettingsOpenWithBlankDocument": false,
|
||||
"SettingsDefaultNewFileLanguage": "plain",
|
||||
"SettingsConfirmCloseDirtyTab": true,
|
||||
"SettingsConfirmClearEditor": true,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue