diff --git a/CHANGELOG.md b/CHANGELOG.md index a649edb..f4a0e1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index a40fe1b..39782d8 100644 --- a/Neon Vision Editor.xcodeproj/project.pbxproj +++ b/Neon Vision Editor.xcodeproj/project.pbxproj @@ -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; diff --git a/Neon Vision Editor/App/AppMenus.swift b/Neon Vision Editor/App/AppMenus.swift index 5f57444..69b4f78 100644 --- a/Neon Vision Editor/App/AppMenus.swift +++ b/Neon Vision Editor/App/AppMenus.swift @@ -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") { diff --git a/Neon Vision Editor/App/NeonVisionEditorApp.swift b/Neon Vision Editor/App/NeonVisionEditorApp.swift index d8d23b3..73a7e40 100644 --- a/Neon Vision Editor/App/NeonVisionEditorApp.swift +++ b/Neon Vision Editor/App/NeonVisionEditorApp.swift @@ -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) } diff --git a/Neon Vision Editor/Core/EditorPerformanceMonitor.swift b/Neon Vision Editor/Core/EditorPerformanceMonitor.swift new file mode 100644 index 0000000..b83695a --- /dev/null +++ b/Neon Vision Editor/Core/EditorPerformanceMonitor.swift @@ -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)) + } +} diff --git a/Neon Vision Editor/Core/RuntimeReliabilityMonitor.swift b/Neon Vision Editor/Core/RuntimeReliabilityMonitor.swift new file mode 100644 index 0000000..a21efaa --- /dev/null +++ b/Neon Vision Editor/Core/RuntimeReliabilityMonitor.swift @@ -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)" + } +} diff --git a/Neon Vision Editor/Data/EditorViewModel.swift b/Neon Vision Editor/Data/EditorViewModel.swift index 04eb625..83efbca 100644 --- a/Neon Vision Editor/Data/EditorViewModel.swift +++ b/Neon Vision Editor/Data/EditorViewModel.swift @@ -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.") } diff --git a/Neon Vision Editor/UI/ContentView+Actions.swift b/Neon Vision Editor/UI/ContentView+Actions.swift index 4e67202..5d0ea79 100644 --- a/Neon Vision Editor/UI/ContentView+Actions.swift +++ b/Neon Vision Editor/UI/ContentView+Actions.swift @@ -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 = [.isRegularFileKey, .isHiddenKey, .fileSizeKey] diff --git a/Neon Vision Editor/UI/ContentView+Toolbar.swift b/Neon Vision Editor/UI/ContentView+Toolbar.swift index b9f08b2..016331e 100644 --- a/Neon Vision Editor/UI/ContentView+Toolbar.swift +++ b/Neon Vision Editor/UI/ContentView+Toolbar.swift @@ -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() diff --git a/Neon Vision Editor/UI/ContentView.swift b/Neon Vision Editor/UI/ContentView.swift index a14c2f2..0ee80f9 100644 --- a/Neon Vision Editor/UI/ContentView.swift +++ b/Neon Vision Editor/UI/ContentView.swift @@ -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 = [] diff --git a/Neon Vision Editor/UI/EditorTextView.swift b/Neon Vision Editor/UI/EditorTextView.swift index fa8883f..fa15059 100644 --- a/Neon Vision Editor/UI/EditorTextView.swift +++ b/Neon Vision Editor/UI/EditorTextView.swift @@ -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 ?? "", diff --git a/Neon Vision Editor/UI/IPadKeyboardShortcutBridge.swift b/Neon Vision Editor/UI/IPadKeyboardShortcutBridge.swift new file mode 100644 index 0000000..56d24aa --- /dev/null +++ b/Neon Vision Editor/UI/IPadKeyboardShortcutBridge.swift @@ -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 diff --git a/Neon Vision Editor/UI/PanelsAndHelpers.swift b/Neon Vision Editor/UI/PanelsAndHelpers.swift index 8153033..e29b068 100644 --- a/Neon Vision Editor/UI/PanelsAndHelpers.swift +++ b/Neon Vision Editor/UI/PanelsAndHelpers.swift @@ -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: "What’s 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") diff --git a/README.md b/README.md index 3b5ab99..81010cb 100644 --- a/README.md +++ b/README.md @@ -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: