mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 13:27:16 +00:00
Prepare v0.4.33 release and iPad shortcut updates
This commit is contained in:
parent
33dda0642d
commit
44c02ea802
14 changed files with 726 additions and 70 deletions
18
CHANGELOG.md
18
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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
59
Neon Vision Editor/Core/EditorPerformanceMonitor.swift
Normal file
59
Neon Vision Editor/Core/EditorPerformanceMonitor.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
65
Neon Vision Editor/Core/RuntimeReliabilityMonitor.swift
Normal file
65
Neon Vision Editor/Core/RuntimeReliabilityMonitor.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
|
|
@ -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.")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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 ?? "",
|
||||
|
|
|
|||
89
Neon Vision Editor/UI/IPadKeyboardShortcutBridge.swift
Normal file
89
Neon Vision Editor/UI/IPadKeyboardShortcutBridge.swift
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
19
README.md
19
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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue