Prepare v0.4.33 release and iPad shortcut updates

This commit is contained in:
h3p 2026-03-03 10:47:45 +01:00
parent 33dda0642d
commit 44c02ea802
14 changed files with 726 additions and 70 deletions

View file

@ -4,6 +4,24 @@ 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.4.33] - 2026-03-03
### Added
- Added performance instrumentation for startup first-paint/first-keystroke and file-open latency in debug builds.
- Added iPad hardware-keyboard shortcut bridging for New Tab, Open, Save, Find, Find in Files, and Command Palette.
- Added local runtime reliability monitoring with previous-run crash bucketing and main-thread stall watchdog logging in debug.
### Improved
- Improved command palette behavior with fuzzy matching, command entries, and recent-selection ranking.
- Improved large-file responsiveness by forcing throttle mode during load/import and reevaluating after idle.
- Improved project-wide search on macOS via ripgrep-backed Find in Files with fallback scanning.
- Improved iPad toolbar usability with larger minimum touch targets for promoted actions.
### Fixed
- Fixed iPad keyboard shortcut implementation to avoid deprecated UIKit key-command initializer usage.
- Fixed startup restore flow to recover unsaved draft checkpoints before blank-document startup mode.
- Fixed command/find panels with explicit accessibility labels, hints, and initial focus behavior.
## [v0.4.32] - 2026-02-27
### Added

View file

@ -361,7 +361,7 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 374;
CURRENT_PROJECT_VERSION = 375;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES;
@ -441,7 +441,7 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 374;
CURRENT_PROJECT_VERSION = 375;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES;

View file

@ -220,6 +220,12 @@ struct NeonVisionMacAppCommands: Commands {
post(.clearEditorRequested)
}
Button("Add Next Match") {
post(.addNextMatchRequested)
}
.keyboardShortcut("d", modifiers: .command)
.disabled(!hasSelectedTab)
Divider()
Button("Toggle Vim Mode") {

View file

@ -49,6 +49,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillTerminate(_ notification: Notification) {
appUpdateManager?.applicationWillTerminate()
RuntimeReliabilityMonitor.shared.markGracefulTermination()
}
@MainActor
@ -220,6 +221,9 @@ struct NeonVisionEditorApp: App {
defaults.set(false, forKey: "NSShowControlCharacters")
defaults.set(true, forKey: whitespaceMigrationKey)
}
RuntimeReliabilityMonitor.shared.markLaunch()
RuntimeReliabilityMonitor.shared.startMainThreadWatchdog()
EditorPerformanceMonitor.shared.markLaunchConfigured()
}
#if os(macOS)
@ -387,6 +391,9 @@ struct NeonVisionEditorApp: App {
.environment(\.grokErrorMessage, $grokErrorMessage)
.tint(.blue)
.onAppear { applyIOSAppearanceOverride() }
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willTerminateNotification)) { _ in
RuntimeReliabilityMonitor.shared.markGracefulTermination()
}
.onChange(of: appearance) { _, _ in applyIOSAppearanceOverride() }
.preferredColorScheme(preferredAppearance)
}

View file

@ -0,0 +1,59 @@
import Foundation
import OSLog
@MainActor
final class EditorPerformanceMonitor {
static let shared = EditorPerformanceMonitor()
private let logger = Logger(subsystem: "h3p.Neon-Vision-Editor", category: "Performance")
private let launchUptime = ProcessInfo.processInfo.systemUptime
private var didLogFirstPaint = false
private var didLogFirstKeystroke = false
private var fileOpenStartUptimeByTabID: [UUID: TimeInterval] = [:]
private init() {}
func markLaunchConfigured() {
#if DEBUG
logger.debug("perf.launch.configured")
#endif
}
func markFirstPaint() {
guard !didLogFirstPaint else { return }
didLogFirstPaint = true
#if DEBUG
logger.debug("perf.first_paint_ms=\(Self.elapsedMilliseconds(since: launchUptime), privacy: .public)")
#endif
}
func markFirstKeystroke() {
guard !didLogFirstKeystroke else { return }
didLogFirstKeystroke = true
#if DEBUG
logger.debug("perf.first_keystroke_ms=\(Self.elapsedMilliseconds(since: launchUptime), privacy: .public)")
#endif
}
func beginFileOpen(tabID: UUID) {
fileOpenStartUptimeByTabID[tabID] = ProcessInfo.processInfo.systemUptime
}
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)
if let byteCount {
logger.debug(
"perf.file_open_ms=\(elapsed, privacy: .public) success=\(success, privacy: .public) bytes=\(byteCount, privacy: .public)"
)
} else {
logger.debug("perf.file_open_ms=\(elapsed, privacy: .public) success=\(success, privacy: .public)")
}
#endif
}
private static func elapsedMilliseconds(since startUptime: TimeInterval) -> Int {
max(0, Int((ProcessInfo.processInfo.systemUptime - startUptime) * 1_000))
}
}

View file

@ -0,0 +1,65 @@
import Foundation
import OSLog
@MainActor
final class RuntimeReliabilityMonitor {
static let shared = RuntimeReliabilityMonitor()
private let logger = Logger(subsystem: "h3p.Neon-Vision-Editor", category: "Reliability")
private let defaults = UserDefaults.standard
private let activeRunKey = "Reliability.ActiveRunMarkerV1"
private let crashBucketPrefix = "Reliability.CrashBucketV1."
private var watchdogTimer: DispatchSourceTimer?
private var lastMainThreadPingUptime = ProcessInfo.processInfo.systemUptime
private init() {}
func markLaunch() {
if defaults.bool(forKey: activeRunKey) {
let key = crashBucketPrefix + currentBucketID()
let current = defaults.integer(forKey: key)
defaults.set(current + 1, forKey: key)
#if DEBUG
logger.warning("reliability.previous_run_unfinished bucket=\(self.currentBucketID(), privacy: .public) count=\(current + 1, privacy: .public)")
#endif
}
defaults.set(true, forKey: activeRunKey)
}
func markGracefulTermination() {
defaults.set(false, forKey: activeRunKey)
}
func startMainThreadWatchdog() {
#if DEBUG
guard watchdogTimer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
timer.schedule(deadline: .now() + 1.0, repeating: 1.0)
timer.setEventHandler { [weak self] in
guard let self else { return }
let now = ProcessInfo.processInfo.systemUptime
let lagMS = Int(max(0, (now - self.lastMainThreadPingUptime) * 1_000))
if lagMS > 450 {
self.logger.warning("reliability.main_thread_lag_ms=\(lagMS, privacy: .public)")
}
DispatchQueue.main.async { [weak self] in
self?.lastMainThreadPingUptime = ProcessInfo.processInfo.systemUptime
}
}
watchdogTimer = timer
timer.resume()
#endif
}
private func currentBucketID() -> String {
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown"
#if os(macOS)
let platform = "macOS"
#elseif os(iOS)
let platform = "iOS"
#else
let platform = "unknown"
#endif
return "\(platform).build\(build)"
}
}

View file

@ -1107,6 +1107,7 @@ class EditorViewModel {
isLargeCandidate: isLargeCandidate
)
)
EditorPerformanceMonitor.shared.beginFileOpen(tabID: tabID)
Task { [weak self] in
guard let self else { return }
do {
@ -1201,10 +1202,16 @@ class EditorViewModel {
isLargeCandidate: result.isLargeCandidate
)
)
EditorPerformanceMonitor.shared.endFileOpen(
tabID: tabID,
success: true,
byteCount: result.content.lengthOfBytes(using: .utf8)
)
}
private func markTabLoadFailed(tabID: UUID) async {
_ = await dispatchTabCommandSerialized(.setLoading(tabID: tabID, isLoading: false))
EditorPerformanceMonitor.shared.endFileOpen(tabID: tabID, success: false, byteCount: nil)
debugLog("Failed to open file.")
}

View file

@ -7,6 +7,11 @@ import UIKit
#endif
extension ContentView {
private struct ProjectEditorOverrides: Decodable {
let indentWidth: Int?
let lineWrapEnabled: Bool?
}
func liveEditorBufferText() -> String? {
#if os(macOS)
if let textView = activeEditorTextView() {
@ -469,6 +474,43 @@ extension ContentView {
#endif
}
func addNextMatchSelection() {
#if os(macOS)
guard let textView = activeEditorTextView() else { return }
let source = textView.string as NSString
guard source.length > 0 else { return }
let existing = textView.selectedRanges
guard let primary = existing.last?.rangeValue, primary.length > 0 else {
return
}
let needle = source.substring(with: primary)
guard !needle.isEmpty else { return }
let opts: NSString.CompareOptions = []
let searchStart = NSMaxRange(primary)
let forward = source.range(
of: needle,
options: opts,
range: NSRange(location: min(searchStart, source.length), length: max(0, source.length - min(searchStart, source.length)))
)
let wrapped = source.range(
of: needle,
options: opts,
range: NSRange(location: 0, length: min(primary.location, source.length))
)
guard let nextRange = forward.toOptional() ?? wrapped.toOptional() else { return }
if existing.contains(where: { $0.rangeValue.location == nextRange.location && $0.rangeValue.length == nextRange.length }) {
return
}
var updated = existing
updated.append(NSValue(range: nextRange))
textView.selectedRanges = updated
textView.scrollRangeToVisible(nextRange)
#endif
}
#if os(macOS)
private func activeEditorTextView() -> NSTextView? {
var candidates: [NSWindow] = []
@ -627,9 +669,41 @@ extension ContentView {
projectRootFolderURL = folderURL
projectTreeNodes = []
quickSwitcherProjectFileURLs = []
applyProjectEditorOverrides(from: folderURL)
refreshProjectTree()
}
func clearProjectEditorOverrides() {
projectOverrideIndentWidth = nil
projectOverrideLineWrapEnabled = nil
if viewModel.isLineWrapEnabled != settingsLineWrapEnabled {
viewModel.isLineWrapEnabled = settingsLineWrapEnabled
}
}
func applyProjectEditorOverrides(from folderURL: URL) {
let configURL = folderURL.appendingPathComponent(".neon-editor.json")
guard let data = try? Data(contentsOf: configURL, options: [.mappedIfSafe]),
let overrides = try? JSONDecoder().decode(ProjectEditorOverrides.self, from: data) else {
clearProjectEditorOverrides()
return
}
if let width = overrides.indentWidth {
projectOverrideIndentWidth = min(max(width, 2), 8)
} else {
projectOverrideIndentWidth = nil
}
projectOverrideLineWrapEnabled = overrides.lineWrapEnabled
if let lineWrapEnabled = overrides.lineWrapEnabled,
viewModel.isLineWrapEnabled != lineWrapEnabled {
viewModel.isLineWrapEnabled = lineWrapEnabled
} else if overrides.lineWrapEnabled == nil, viewModel.isLineWrapEnabled != settingsLineWrapEnabled {
viewModel.isLineWrapEnabled = settingsLineWrapEnabled
}
}
private nonisolated static func readChildren(of directory: URL, recursive: Bool) -> [ProjectTreeNode] {
if Task.isCancelled { return [] }
let fm = FileManager.default
@ -677,6 +751,17 @@ extension ContentView {
maxResults: Int
) async -> [FindInFilesMatch] {
await Task.detached(priority: .userInitiated) {
#if os(macOS)
if let ripgrepMatches = findInFilesWithRipgrep(
root: root,
query: query,
caseSensitive: caseSensitive,
maxResults: maxResults
) {
return ripgrepMatches
}
#endif
let files = searchableProjectFiles(at: root)
var results: [FindInFilesMatch] = []
results.reserveCapacity(min(maxResults, 200))
@ -697,6 +782,110 @@ extension ContentView {
}.value
}
#if os(macOS)
private nonisolated static func findInFilesWithRipgrep(
root: URL,
query: String,
caseSensitive: Bool,
maxResults: Int
) -> [FindInFilesMatch]? {
guard maxResults > 0 else { return [] }
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.currentDirectoryURL = root
process.arguments = [
"rg",
"--json",
"--line-number",
"--column",
"--max-count",
String(maxResults),
caseSensitive ? "-s" : "-i",
query,
root.path
]
let outputPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = Pipe()
do {
try process.run()
} catch {
return nil
}
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0 || process.terminationStatus == 1 else {
return nil
}
guard !data.isEmpty else { return [] }
var results: [FindInFilesMatch] = []
results.reserveCapacity(min(maxResults, 200))
var contentByPath: [String: String] = [:]
let lines = String(decoding: data, as: UTF8.self).split(separator: "\n", omittingEmptySubsequences: true)
for line in lines {
if results.count >= maxResults { break }
guard let eventData = line.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: eventData) as? [String: Any],
(json["type"] as? String) == "match",
let payload = json["data"] as? [String: Any],
let path = (payload["path"] as? [String: Any])?["text"] as? String,
let lineNumber = payload["line_number"] as? Int,
let linesDict = payload["lines"] as? [String: Any],
let lineText = linesDict["text"] as? String,
let submatches = payload["submatches"] as? [[String: Any]],
let first = submatches.first,
let start = first["start"] as? Int,
let end = first["end"] as? Int else {
continue
}
let column = max(1, start + 1)
let length = max(1, end - start)
let snippet = lineText.trimmingCharacters(in: .newlines)
let fileURL = URL(fileURLWithPath: path)
let fileContent: String = {
if let cached = contentByPath[path] {
return cached
}
let loaded = String(decoding: (try? Data(contentsOf: fileURL, options: [.mappedIfSafe])) ?? Data(), as: UTF8.self)
contentByPath[path] = loaded
return loaded
}()
let offset = utf16LocationForLine(content: fileContent, lineOneBased: lineNumber)
results.append(
FindInFilesMatch(
id: "\(path)#\(offset + start)",
fileURL: fileURL,
line: lineNumber,
column: column,
snippet: snippet.isEmpty ? "(empty line)" : snippet,
rangeLocation: offset + start,
rangeLength: length
)
)
}
return results
}
private nonisolated static func utf16LocationForLine(content: String, lineOneBased: Int) -> Int {
guard lineOneBased > 1 else { return 0 }
var line = 1
var utf16Offset = 0
for codeUnit in content.utf16 {
if line >= lineOneBased { break }
utf16Offset += 1
if codeUnit == 10 {
line += 1
}
}
return utf16Offset
}
#endif
private nonisolated static func searchableProjectFiles(at root: URL) -> [URL] {
let fm = FileManager.default
let keys: Set<URLResourceKey> = [.isRegularFileKey, .isHiddenKey, .fileSizeKey]

View file

@ -801,9 +801,13 @@ extension ContentView {
languagePickerControl
ForEach(iPadPromotedActions, id: \.self) { action in
iPadToolbarActionControl(action)
.frame(minWidth: 40, minHeight: 40)
.contentShape(Rectangle())
}
ForEach(iPadAlwaysVisibleActions, id: \.self) { action in
iPadToolbarActionControl(action)
.frame(minWidth: 40, minHeight: 40)
.contentShape(Rectangle())
}
if !iPadOverflowActions.isEmpty {
Divider()

View file

@ -124,19 +124,18 @@ struct ContentView: View {
let createdAt: Date
}
#if os(iOS)
private struct IOSSavedDraftTab: Codable {
private struct SavedDraftTabSnapshot: Codable {
let name: String
let content: String
let language: String
let fileURLString: String?
}
private struct IOSSavedDraftSnapshot: Codable {
let tabs: [IOSSavedDraftTab]
private struct SavedDraftSnapshot: Codable {
let tabs: [SavedDraftTabSnapshot]
let selectedIndex: Int?
let createdAt: Date
}
#endif
// Environment-provided view model and theme/error bindings
@Environment(EditorViewModel.self) var viewModel
@ -228,6 +227,8 @@ struct ContentView: View {
@State var projectRootFolderURL: URL? = nil
@State var projectTreeNodes: [ProjectTreeNode] = []
@State var projectTreeRefreshGeneration: Int = 0
@State var projectOverrideIndentWidth: Int? = nil
@State var projectOverrideLineWrapEnabled: Bool? = nil
@State var showProjectFolderPicker: Bool = false
@State var projectFolderSecurityURL: URL? = nil
@State var pendingCloseTabID: UUID? = nil
@ -241,6 +242,7 @@ struct ContentView: View {
@State var showQuickSwitcher: Bool = false
@State var quickSwitcherQuery: String = ""
@State var quickSwitcherProjectFileURLs: [URL] = []
@State private var quickSwitcherRecentItemIDs: [String] = []
@State var showFindInFiles: Bool = false
@State var findInFilesQuery: String = ""
@State var findInFilesCaseSensitive: Bool = false
@ -283,6 +285,9 @@ struct ContentView: View {
@State private var whitespaceInspectorMessage: String? = nil
@State private var didApplyStartupBehavior: Bool = false
@State private var didRunInitialWindowLayoutSetup: Bool = false
@State private var pendingLargeFileModeReevaluation: DispatchWorkItem? = nil
@State private var recoverySnapshotIdentifier: String = UUID().uuidString
private let quickSwitcherRecentsDefaultsKey = "QuickSwitcherRecentItemsV1"
#if USE_FOUNDATION_MODELS && canImport(FoundationModels)
var appleModelAvailable: Bool { true }
@ -1330,12 +1335,17 @@ struct ContentView: View {
}
}
.onChange(of: viewModel.selectedTab?.id) { _, _ in
if viewModel.selectedTab?.isLargeFileCandidate == true {
updateLargeFileModeForCurrentContext()
scheduleLargeFileModeReevaluation(after: 0.9)
scheduleHighlightRefresh()
}
.onChange(of: viewModel.selectedTab?.isLoadingContent ?? false) { _, isLoading in
if isLoading {
if !largeFileModeEnabled {
largeFileModeEnabled = true
}
} else {
updateLargeFileMode(for: currentContentBinding.wrappedValue)
scheduleLargeFileModeReevaluation(after: 0.8)
}
scheduleHighlightRefresh()
}
@ -1402,7 +1412,7 @@ struct ContentView: View {
}
func updateLargeFileMode(for text: String) {
if viewModel.selectedTab?.isLargeFileCandidate == true {
if droppedFileLoadInProgress || viewModel.selectedTab?.isLoadingContent == true {
if !largeFileModeEnabled {
largeFileModeEnabled = true
scheduleHighlightRefresh()
@ -1448,6 +1458,19 @@ struct ContentView: View {
}
}
private func updateLargeFileModeForCurrentContext() {
updateLargeFileMode(for: currentContentBinding.wrappedValue)
}
private func scheduleLargeFileModeReevaluation(after delay: TimeInterval) {
pendingLargeFileModeReevaluation?.cancel()
let work = DispatchWorkItem {
updateLargeFileModeForCurrentContext()
}
pendingLargeFileModeReevaluation = work
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
}
func recordDiagnostic(_ message: String) {
#if DEBUG
print("[NVE] \(message)")
@ -1537,6 +1560,10 @@ struct ContentView: View {
quickSwitcherQuery = ""
showQuickSwitcher = true
}
.onReceive(NotificationCenter.default.publisher(for: .addNextMatchRequested)) { notif in
guard matchesCurrentWindow(notif) else { return }
addNextMatchSelection()
}
.onReceive(NotificationCenter.default.publisher(for: .showFindInFilesRequested)) { notif in
guard matchesCurrentWindow(notif) else { return }
if findInFilesQuery.isEmpty {
@ -1696,6 +1723,20 @@ struct ContentView: View {
.navigationTitle("Neon Vision Editor")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.background(
IPadKeyboardShortcutBridge(
onNewTab: { viewModel.addNewTab() },
onOpenFile: { openFileFromToolbar() },
onSave: { saveCurrentTabFromToolbar() },
onFind: { showFindReplace = true },
onFindInFiles: { showFindInFiles = true },
onQuickOpen: {
quickSwitcherQuery = ""
showQuickSwitcher = true
}
)
.frame(width: 0, height: 0)
)
#endif
}
@ -1705,8 +1746,9 @@ struct ContentView: View {
handleSettingsAndEditorDefaultsOnAppear()
}
.onChange(of: settingsLineWrapEnabled) { _, enabled in
if viewModel.isLineWrapEnabled != enabled {
viewModel.isLineWrapEnabled = enabled
let target = projectOverrideLineWrapEnabled ?? enabled
if viewModel.isLineWrapEnabled != target {
viewModel.isLineWrapEnabled = target
}
}
.onReceive(NotificationCenter.default.publisher(for: .whitespaceScalarInspectionResult)) { notif in
@ -1716,6 +1758,7 @@ struct ContentView: View {
}
}
.onChange(of: viewModel.isLineWrapEnabled) { _, enabled in
guard projectOverrideLineWrapEnabled == nil else { return }
if settingsLineWrapEnabled != enabled {
settingsLineWrapEnabled = enabled
}
@ -1742,9 +1785,7 @@ struct ContentView: View {
}
.onChange(of: viewModel.tabsObservationToken) { _, _ in
persistSessionIfReady()
#if os(iOS)
persistUnsavedDraftSnapshotIfNeeded()
#endif
}
.onOpenURL { url in
viewModel.openFile(url: url)
@ -1763,6 +1804,9 @@ struct ContentView: View {
private func handleSettingsAndEditorDefaultsOnAppear() {
let defaults = UserDefaults.standard
if let saved = defaults.stringArray(forKey: quickSwitcherRecentsDefaultsKey) {
quickSwitcherRecentItemIDs = saved
}
if UserDefaults.standard.object(forKey: "SettingsAutoIndent") == nil {
autoIndentEnabled = true
}
@ -1784,11 +1828,13 @@ struct ContentView: View {
} else {
isAutoCompletionEnabled = defaults.bool(forKey: "SettingsCompletionEnabled")
}
viewModel.isLineWrapEnabled = settingsLineWrapEnabled
viewModel.isLineWrapEnabled = effectiveLineWrapEnabled
syncAppleCompletionAvailability()
}
private func handleStartupOnAppear() {
EditorPerformanceMonitor.shared.markFirstPaint()
if !didRunInitialWindowLayoutSetup {
// Start with sidebars collapsed only once; otherwise toggles can get reset on layout transitions.
viewModel.showSidebar = false
@ -1822,6 +1868,7 @@ struct ContentView: View {
completionTask?.cancel()
lastCompletionTriggerSignature = ""
pendingHighlightRefresh?.cancel()
pendingLargeFileModeReevaluation?.cancel()
completionCache.removeAll(keepingCapacity: false)
if let number = hostWindowNumber,
let window = NSApp.window(withWindowNumber: number),
@ -2044,6 +2091,14 @@ struct ContentView: View {
#endif
}
private var effectiveIndentWidth: Int {
projectOverrideIndentWidth ?? indentWidth
}
private var effectiveLineWrapEnabled: Bool {
projectOverrideLineWrapEnabled ?? settingsLineWrapEnabled
}
private func applyStartupBehaviorIfNeeded() {
guard !didApplyStartupBehavior else { return }
@ -2051,6 +2106,7 @@ struct ContentView: View {
viewModel.resetTabsForSessionRestore()
viewModel.addNewTab()
projectRootFolderURL = nil
clearProjectEditorOverrides()
projectTreeNodes = []
quickSwitcherProjectFileURLs = []
didApplyStartupBehavior = true
@ -2064,10 +2120,17 @@ struct ContentView: View {
return
}
if restoreUnsavedDraftSnapshotIfAvailable() {
didApplyStartupBehavior = true
persistSessionIfReady()
return
}
if openWithBlankDocument {
viewModel.resetTabsForSessionRestore()
viewModel.addNewTab()
projectRootFolderURL = nil
clearProjectEditorOverrides()
projectTreeNodes = []
quickSwitcherProjectFileURLs = []
didApplyStartupBehavior = true
@ -2075,14 +2138,6 @@ struct ContentView: View {
return
}
#if os(iOS)
if restoreUnsavedDraftSnapshotIfAvailable() {
didApplyStartupBehavior = true
persistSessionIfReady()
return
}
#endif
// Restore last session first when enabled.
if reopenLastSession {
if projectRootFolderURL == nil, let restoredProjectFolderURL = restoredLastSessionProjectFolderURL() {
@ -2319,34 +2374,33 @@ struct ContentView: View {
}
#endif
#if os(iOS)
private var unsavedDraftSnapshotKey: String { "IOSUnsavedDraftSnapshotV1" }
private var lastSessionBookmarksKey: String { "LastSessionFileBookmarks" }
private var lastSessionSelectedBookmarkKey: String { "LastSessionSelectedFileBookmark" }
private var lastSessionProjectFolderBookmarkKey: String { "LastSessionProjectFolderBookmark" }
private var unsavedDraftSnapshotRegistryKey: String { "UnsavedDraftSnapshotRegistryV1" }
private var unsavedDraftSnapshotKey: String { "UnsavedDraftSnapshotV2.\(recoverySnapshotIdentifier)" }
private var maxPersistedDraftTabs: Int { 20 }
private var maxPersistedDraftUTF16Length: Int { 2_000_000 }
private func persistUnsavedDraftSnapshotIfNeeded() {
let defaults = UserDefaults.standard
let dirtyTabs = viewModel.tabs.filter(\.isDirty)
var registry = defaults.stringArray(forKey: unsavedDraftSnapshotRegistryKey) ?? []
guard !dirtyTabs.isEmpty else {
UserDefaults.standard.removeObject(forKey: unsavedDraftSnapshotKey)
defaults.removeObject(forKey: unsavedDraftSnapshotKey)
registry.removeAll { $0 == unsavedDraftSnapshotKey }
defaults.set(registry, forKey: unsavedDraftSnapshotRegistryKey)
return
}
var savedTabs: [IOSSavedDraftTab] = []
var savedTabs: [SavedDraftTabSnapshot] = []
savedTabs.reserveCapacity(min(dirtyTabs.count, maxPersistedDraftTabs))
for tab in dirtyTabs.prefix(maxPersistedDraftTabs) {
let content = tab.content
let nsContent = content as NSString
let clampedContent: String
if nsContent.length > maxPersistedDraftUTF16Length {
clampedContent = nsContent.substring(to: maxPersistedDraftUTF16Length)
} else {
clampedContent = content
}
let clampedContent = nsContent.length > maxPersistedDraftUTF16Length
? nsContent.substring(to: maxPersistedDraftUTF16Length)
: content
savedTabs.append(
IOSSavedDraftTab(
SavedDraftTabSnapshot(
name: tab.name,
content: clampedContent,
language: tab.language,
@ -2360,19 +2414,36 @@ struct ContentView: View {
return dirtyTabs.firstIndex(where: { $0.id == selectedID })
}()
let snapshot = IOSSavedDraftSnapshot(tabs: savedTabs, selectedIndex: selectedIndex)
let snapshot = SavedDraftSnapshot(tabs: savedTabs, selectedIndex: selectedIndex, createdAt: Date())
guard let encoded = try? JSONEncoder().encode(snapshot) else { return }
UserDefaults.standard.set(encoded, forKey: unsavedDraftSnapshotKey)
defaults.set(encoded, forKey: unsavedDraftSnapshotKey)
if !registry.contains(unsavedDraftSnapshotKey) {
registry.append(unsavedDraftSnapshotKey)
defaults.set(registry, forKey: unsavedDraftSnapshotRegistryKey)
}
}
private func restoreUnsavedDraftSnapshotIfAvailable() -> Bool {
guard let data = UserDefaults.standard.data(forKey: unsavedDraftSnapshotKey),
let snapshot = try? JSONDecoder().decode(IOSSavedDraftSnapshot.self, from: data),
!snapshot.tabs.isEmpty else {
return false
}
let defaults = UserDefaults.standard
let keys = defaults.stringArray(forKey: unsavedDraftSnapshotRegistryKey) ?? []
guard !keys.isEmpty else { return false }
let restoredTabs = snapshot.tabs.map { saved in
var snapshots: [SavedDraftSnapshot] = []
for key in keys {
guard let data = defaults.data(forKey: key),
let snapshot = try? JSONDecoder().decode(SavedDraftSnapshot.self, from: data),
!snapshot.tabs.isEmpty else {
continue
}
snapshots.append(snapshot)
}
guard !snapshots.isEmpty else { return false }
snapshots.sort { $0.createdAt < $1.createdAt }
let mergedTabs = snapshots.flatMap(\.tabs)
guard !mergedTabs.isEmpty else { return false }
let restoredTabs = mergedTabs.map { saved in
EditorViewModel.RestoredTabSnapshot(
name: saved.name,
content: saved.content,
@ -2383,10 +2454,20 @@ struct ContentView: View {
lastSavedFingerprint: nil
)
}
viewModel.restoreTabsFromSnapshot(restoredTabs, selectedIndex: snapshot.selectedIndex)
viewModel.restoreTabsFromSnapshot(restoredTabs, selectedIndex: nil)
for key in keys {
defaults.removeObject(forKey: key)
}
defaults.removeObject(forKey: unsavedDraftSnapshotRegistryKey)
return true
}
#if os(iOS)
private var lastSessionBookmarksKey: String { "LastSessionFileBookmarks" }
private var lastSessionSelectedBookmarkKey: String { "LastSessionSelectedFileBookmark" }
private var lastSessionProjectFolderBookmarkKey: String { "LastSessionProjectFolderBookmark" }
private func persistLastSessionSecurityScopedBookmarks(fileURLs: [URL], selectedURL: URL?) {
let bookmarkData = fileURLs.compactMap { makeSecurityScopedBookmarkData(for: $0) }
UserDefaults.standard.set(bookmarkData, forKey: lastSessionBookmarksKey)
@ -2961,7 +3042,7 @@ struct ContentView: View {
showScopeGuides: effectiveScopeGuides,
highlightScopeBackground: effectiveScopeBackground,
indentStyle: indentStyle,
indentWidth: indentWidth,
indentWidth: effectiveIndentWidth,
autoIndentEnabled: autoIndentEnabled,
autoCloseBracketsEnabled: autoCloseBracketsEnabled,
highlightRefreshToken: highlightRefreshToken,
@ -3762,6 +3843,16 @@ struct ContentView: View {
private var quickSwitcherItems: [QuickFileSwitcherPanel.Item] {
var items: [QuickFileSwitcherPanel.Item] = []
let fileURLSet = Set(viewModel.tabs.compactMap { $0.fileURL?.standardizedFileURL.path })
let commandItems: [QuickFileSwitcherPanel.Item] = [
.init(id: "cmd:new_tab", title: "New Tab", subtitle: "Create a new empty tab"),
.init(id: "cmd:open_file", title: "Open File", subtitle: "Open files from disk"),
.init(id: "cmd:save_file", title: "Save", subtitle: "Save current tab"),
.init(id: "cmd:save_as", title: "Save As", subtitle: "Save current tab to a new file"),
.init(id: "cmd:find_replace", title: "Find and Replace", subtitle: "Search and replace in current document"),
.init(id: "cmd:find_in_files", title: "Find in Files", subtitle: "Search across project files"),
.init(id: "cmd:toggle_sidebar", title: "Toggle Sidebar", subtitle: "Show or hide the outline sidebar")
]
items.append(contentsOf: commandItems)
for tab in viewModel.tabs {
let subtitle = tab.fileURL?.path ?? "Open tab"
@ -3786,17 +3877,35 @@ struct ContentView: View {
)
}
let query = quickSwitcherQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !query.isEmpty else { return Array(items.prefix(300)) }
return Array(
items.filter {
$0.title.lowercased().contains(query) || $0.subtitle.lowercased().contains(query)
let query = quickSwitcherQuery.trimmingCharacters(in: .whitespacesAndNewlines)
if query.isEmpty {
return Array(
items
.sorted { quickSwitcherRecencyScore(for: $0.id) > quickSwitcherRecencyScore(for: $1.id) }
.prefix(300)
)
}
let ranked = items.compactMap { item -> (QuickFileSwitcherPanel.Item, Int)? in
guard let score = quickSwitcherMatchScore(for: item, query: query) else { return nil }
return (item, score + quickSwitcherRecencyScore(for: item.id))
}
.sorted {
if $0.1 == $1.1 {
return $0.0.title.localizedCaseInsensitiveCompare($1.0.title) == .orderedAscending
}
.prefix(300)
)
return $0.1 > $1.1
}
return Array(ranked.prefix(300).map(\.0))
}
private func selectQuickSwitcherItem(_ item: QuickFileSwitcherPanel.Item) {
rememberQuickSwitcherSelection(item.id)
if item.id.hasPrefix("cmd:") {
performQuickSwitcherCommand(item.id)
return
}
if item.id.hasPrefix("tab:") {
let raw = String(item.id.dropFirst(4))
if let id = UUID(uuidString: raw) {
@ -3810,6 +3919,81 @@ struct ContentView: View {
}
}
private func performQuickSwitcherCommand(_ commandID: String) {
switch commandID {
case "cmd:new_tab":
viewModel.addNewTab()
case "cmd:open_file":
openFileFromToolbar()
case "cmd:save_file":
saveCurrentTabFromToolbar()
case "cmd:save_as":
saveCurrentTabAsFromToolbar()
case "cmd:find_replace":
showFindReplace = true
case "cmd:find_in_files":
showFindInFiles = true
case "cmd:toggle_sidebar":
viewModel.showSidebar.toggle()
default:
break
}
}
private func rememberQuickSwitcherSelection(_ itemID: String) {
quickSwitcherRecentItemIDs.removeAll { $0 == itemID }
quickSwitcherRecentItemIDs.insert(itemID, at: 0)
if quickSwitcherRecentItemIDs.count > 30 {
quickSwitcherRecentItemIDs = Array(quickSwitcherRecentItemIDs.prefix(30))
}
UserDefaults.standard.set(quickSwitcherRecentItemIDs, forKey: quickSwitcherRecentsDefaultsKey)
}
private func quickSwitcherRecencyScore(for itemID: String) -> Int {
guard let index = quickSwitcherRecentItemIDs.firstIndex(of: itemID) else { return 0 }
return max(0, 120 - (index * 5))
}
private func quickSwitcherMatchScore(for item: QuickFileSwitcherPanel.Item, query: String) -> Int? {
let normalizedQuery = query.lowercased()
let title = item.title.lowercased()
let subtitle = item.subtitle.lowercased()
if title.hasPrefix(normalizedQuery) {
return 320
}
if title.contains(normalizedQuery) {
return 240
}
if subtitle.contains(normalizedQuery) {
return 180
}
if isFuzzyMatch(needle: normalizedQuery, haystack: title) {
return 120
}
if isFuzzyMatch(needle: normalizedQuery, haystack: subtitle) {
return 90
}
return nil
}
private func isFuzzyMatch(needle: String, haystack: String) -> Bool {
if needle.isEmpty { return true }
var cursor = haystack.startIndex
for ch in needle {
var found = false
while cursor < haystack.endIndex {
if haystack[cursor] == ch {
found = true
cursor = haystack.index(after: cursor)
break
}
cursor = haystack.index(after: cursor)
}
if !found { return false }
}
return true
}
private func startFindInFiles() {
guard let root = projectRootFolderURL else {
findInFilesResults = []

View file

@ -2313,6 +2313,7 @@ struct CustomTextEditor: NSViewRepresentable {
shouldChangeTextIn affectedCharRange: NSRange,
replacementString: String?
) -> Bool {
EditorPerformanceMonitor.shared.markFirstKeystroke()
guard !parent.isTabLoadingContent,
parent.documentID != nil,
let replacementString else {
@ -3851,6 +3852,7 @@ struct CustomTextEditor: UIViewRepresentable {
}
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
EditorPerformanceMonitor.shared.markFirstKeystroke()
if text == "\t" {
if let expansion = EmmetExpander.expansionIfPossible(
in: textView.text ?? "",

View file

@ -0,0 +1,89 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
struct IPadKeyboardShortcutBridge: UIViewRepresentable {
let onNewTab: () -> Void
let onOpenFile: () -> Void
let onSave: () -> Void
let onFind: () -> Void
let onFindInFiles: () -> Void
let onQuickOpen: () -> Void
func makeUIView(context: Context) -> KeyboardCommandView {
let view = KeyboardCommandView()
view.onNewTab = onNewTab
view.onOpenFile = onOpenFile
view.onSave = onSave
view.onFind = onFind
view.onFindInFiles = onFindInFiles
view.onQuickOpen = onQuickOpen
return view
}
func updateUIView(_ uiView: KeyboardCommandView, context: Context) {
uiView.onNewTab = onNewTab
uiView.onOpenFile = onOpenFile
uiView.onSave = onSave
uiView.onFind = onFind
uiView.onFindInFiles = onFindInFiles
uiView.onQuickOpen = onQuickOpen
uiView.refreshFirstResponderStatus()
}
}
final class KeyboardCommandView: UIView {
var onNewTab: (() -> Void)?
var onOpenFile: (() -> Void)?
var onSave: (() -> Void)?
var onFind: (() -> Void)?
var onFindInFiles: (() -> Void)?
var onQuickOpen: (() -> Void)?
override var canBecomeFirstResponder: Bool { true }
override var keyCommands: [UIKeyCommand]? {
guard UIDevice.current.userInterfaceIdiom == .pad else { return [] }
let newTabCommand = UIKeyCommand(input: "t", modifierFlags: .command, action: #selector(newTab))
newTabCommand.discoverabilityTitle = "New Tab"
let openFileCommand = UIKeyCommand(input: "o", modifierFlags: .command, action: #selector(openFile))
openFileCommand.discoverabilityTitle = "Open File"
let saveCommand = UIKeyCommand(input: "s", modifierFlags: .command, action: #selector(saveFile))
saveCommand.discoverabilityTitle = "Save"
let findCommand = UIKeyCommand(input: "f", modifierFlags: .command, action: #selector(handleFindCommand))
findCommand.discoverabilityTitle = "Find"
let findInFilesCommand = UIKeyCommand(input: "f", modifierFlags: [.command, .shift], action: #selector(findInFiles))
findInFilesCommand.discoverabilityTitle = "Find in Files"
let quickOpenCommand = UIKeyCommand(input: "p", modifierFlags: .command, action: #selector(quickOpen))
quickOpenCommand.discoverabilityTitle = "Quick Open"
return [
newTabCommand,
openFileCommand,
saveCommand,
findCommand,
findInFilesCommand,
quickOpenCommand
]
}
override func didMoveToWindow() {
super.didMoveToWindow()
refreshFirstResponderStatus()
}
func refreshFirstResponderStatus() {
guard window != nil, UIDevice.current.userInterfaceIdiom == .pad else { return }
DispatchQueue.main.async { [weak self] in
_ = self?.becomeFirstResponder()
}
}
@objc private func newTab() { onNewTab?() }
@objc private func openFile() { onOpenFile?() }
@objc private func saveFile() { onSave?() }
@objc private func handleFindCommand() { onFind?() }
@objc private func findInFiles() { onFindInFiles?() }
@objc private func quickOpen() { onQuickOpen?() }
}
#endif

View file

@ -168,13 +168,17 @@ struct QuickFileSwitcherPanel: View {
let items: [Item]
let onSelect: (Item) -> Void
@Environment(\.dismiss) private var dismiss
@FocusState private var queryFieldFocused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Quick Open")
Text("Command Palette")
.font(.headline)
TextField("Search files and tabs", text: $query)
TextField("Search commands, files, and tabs", text: $query)
.textFieldStyle(.roundedBorder)
.accessibilityLabel("Command Palette Search")
.accessibilityHint("Type to search commands, files, and tabs")
.focused($queryFieldFocused)
List(items) { item in
Button {
@ -191,8 +195,12 @@ struct QuickFileSwitcherPanel: View {
}
}
.buttonStyle(.plain)
.accessibilityLabel(item.title)
.accessibilityValue(item.subtitle)
.accessibilityHint("Opens the selected item")
}
.listStyle(.plain)
.accessibilityLabel("Command Palette Results")
HStack {
Text("\(items.count) results")
@ -204,6 +212,9 @@ struct QuickFileSwitcherPanel: View {
}
.padding(16)
.frame(minWidth: 520, minHeight: 380)
.onAppear {
queryFieldFocused = true
}
}
}
@ -225,6 +236,7 @@ struct FindInFilesPanel: View {
let onSearch: () -> Void
let onSelect: (FindInFilesMatch) -> Void
@Environment(\.dismiss) private var dismiss
@FocusState private var queryFieldFocused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@ -235,12 +247,17 @@ struct FindInFilesPanel: View {
TextField("Search project files", text: $query)
.textFieldStyle(.roundedBorder)
.onSubmit { onSearch() }
.accessibilityLabel("Find in Files Search")
.accessibilityHint("Enter text to search across project files")
.focused($queryFieldFocused)
Button("Search") { onSearch() }
.disabled(query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.accessibilityLabel("Search Files")
}
Toggle("Case Sensitive", isOn: $caseSensitive)
.accessibilityLabel("Case Sensitive Search")
List(results) { match in
Button {
@ -261,8 +278,12 @@ struct FindInFilesPanel: View {
}
}
.buttonStyle(.plain)
.accessibilityLabel("\(match.fileURL.lastPathComponent) line \(match.line) column \(match.column)")
.accessibilityValue(match.snippet)
.accessibilityHint("Open match in editor")
}
.listStyle(.plain)
.accessibilityLabel("Find in Files Results")
HStack {
Text(statusMessage)
@ -274,6 +295,9 @@ struct FindInFilesPanel: View {
}
.padding(16)
.frame(minWidth: 620, minHeight: 420)
.onAppear {
queryFieldFocused = true
}
}
}
@ -313,12 +337,12 @@ struct WelcomeTourView: View {
private let pages: [TourPage] = [
TourPage(
title: "Whats New in This Release",
subtitle: "Major changes since v0.4.31:",
subtitle: "Major changes since v0.4.32:",
bullets: [
"Added native macOS `SettingsLink` wiring for the menu bar entry so it opens the Settings scene through the system path.",
"Improved macOS command integration by preserving the system app-settings command group and standard Settings routing behavior.",
"Improved project-folder last-session restoration reliability by keeping security-scoped folder access active before rebuilding the sidebar tree.",
"Fixed non-standard Settings shortcut mapping by restoring the macOS standard `Cmd+,` behavior."
"Added performance instrumentation for startup first-paint/first-keystroke and file-open latency in debug builds.",
"Added iPad hardware-keyboard shortcut bridging for New Tab, Open, Save, Find, Find in Files, and Command Palette.",
"Added local runtime reliability monitoring with previous-run crash bucketing and main-thread stall watchdog logging in debug.",
"Improved command palette behavior with fuzzy matching, command entries, and recent-selection ranking."
],
iconName: "sparkles.rectangle.stack",
colors: [Color(red: 0.40, green: 0.28, blue: 0.90), Color(red: 0.96, green: 0.46, blue: 0.55)],
@ -747,6 +771,7 @@ extension Notification.Name {
static let toggleBrainDumpModeRequested = Notification.Name("toggleBrainDumpModeRequested")
static let zoomEditorFontRequested = Notification.Name("zoomEditorFontRequested")
static let inspectWhitespaceScalarsRequested = Notification.Name("inspectWhitespaceScalarsRequested")
static let addNextMatchRequested = Notification.Name("addNextMatchRequested")
static let whitespaceScalarInspectionResult = Notification.Name("whitespaceScalarInspectionResult")
static let insertBracketHelperTokenRequested = Notification.Name("insertBracketHelperTokenRequested")
static let keyboardAccessoryBarVisibilityChanged = Notification.Name("keyboardAccessoryBarVisibilityChanged")

View file

@ -30,7 +30,7 @@
> Status: **active release**
> Latest release: **v0.4.32**
> Latest release: **v0.4.33**
> Platform target: **macOS 26 (Tahoe)** compatible with **macOS Sequoia**
> Apple Silicon: tested / Intel: not tested
> Last updated (README): **2026-03-01** for release line **v0.4.32 (2026-02-27)**
@ -60,7 +60,7 @@
Prebuilt binaries are available on [GitHub Releases](https://github.com/h3pdesign/Neon-Vision-Editor/releases).
- Latest release: **v0.4.32**
- Latest release: **v0.4.33**
- Channel: **Stable** (GitHub Releases)
- Apple AppStore [On the AppStore](https://apps.apple.com/de/app/neon-vision-editor/id6758950965)
- TestFlight beta: [Join here](https://testflight.apple.com/join/YWB2fGAP)
@ -235,12 +235,13 @@ Availability legend: `Full` = complete support, `Partial` = available with platf
## Changelog
### Recent improvements (post-v0.4.32, in progress)
### v0.4.33 (summary)
- iPad toolbar now keeps core actions visible more consistently (Settings, Search, Project Sidebar, Markdown Preview) with improved width adaptation.
- iPad Markdown Preview flow now prioritizes preview space by hiding the project sidebar when needed.
- iOS/iPad Settings polish: improved German localization coverage, centered tab header presentation, and cleaner section grouping/cards.
- macOS window-controls stability refinement to reduce top-left control jitter during settings/tab transitions.
- Added performance instrumentation for startup first-paint/first-keystroke and file-open latency in debug builds.
- Added iPad hardware-keyboard shortcut bridging for New Tab, Open, Save, Find, Find in Files, and Command Palette.
- Added local runtime reliability monitoring with previous-run crash bucketing and main-thread stall watchdog logging in debug.
- Improved command palette behavior with fuzzy matching, command entries, and recent-selection ranking.
- Improved large-file responsiveness by forcing throttle mode during load/import and reevaluating after idle.
### v0.4.32 (summary)
@ -277,12 +278,12 @@ Full release history: [`CHANGELOG.md`](CHANGELOG.md)
## Release Integrity
- Tag: `v0.4.32`
- Tag: `v0.4.33`
- Tagged commit: `1c31306`
- Verify local tag target:
```bash
git rev-parse --verify v0.4.32
git rev-parse --verify v0.4.33
```
- Verify downloaded artifact checksum locally: