diff --git a/CHANGELOG.md b/CHANGELOG.md index 6822c18..706d139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format follows *Keep a Changelog*. Versions use semantic versioning with pre ## [Unreleased] +### Added +- Added editor performance presets in Settings (`Balanced`, `Large Files`, `Battery`) with shared runtime mapping. +- Added configurable project navigator placement (`Left`/`Right`) for project-structure sidebar layout. +- Added richer updater diagnostics details in Settings: staged update summary, last install-attempt summary, and recent sanitized log snippet. + +### Improved +- Improved iOS/iPadOS large-file responsiveness by lowering automatic large-file thresholds and applying preset-based tuning. +- Improved project-sidebar open flow by short-circuiting redundant opens when the selected file is already active. + +### Fixed +- Fixed missing diagnostics reset workflow by adding a dedicated `Clear Diagnostics` action that also clears file-open timing snapshots. + ## [v0.5.1] - 2026-03-08 ### Added diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index ab33ece..c0906bb 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 = 444; + CURRENT_PROJECT_VERSION = 445; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; @@ -444,7 +444,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 444; + CURRENT_PROJECT_VERSION = 445; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; diff --git a/Neon Vision Editor/Core/AppUpdateManager.swift b/Neon Vision Editor/Core/AppUpdateManager.swift index b2eca28..b0c9373 100644 --- a/Neon Vision Editor/Core/AppUpdateManager.swift +++ b/Neon Vision Editor/Core/AppUpdateManager.swift @@ -388,6 +388,49 @@ final class AppUpdateManager: ObservableObject { installDispatchScheduled = false } + var stagedUpdateVersionSummary: String { + let stagedURL = preparedUpdateAppURL ?? defaults.string(forKey: Self.stagedUpdatePathKey).map(URL.init(fileURLWithPath:)) + guard let stagedURL else { return "None" } + let version = Self.readBundleShortVersionString(of: stagedURL) ?? "unknown" + return "v\(version)" + } + + var lastInstallAttemptSummary: String { + if let installMessage, !installMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return Self.sanitizedDiagnosticSummary(installMessage) + } + return "No install attempt yet." + } + + var recentLogSnippet: String { + let fm = FileManager.default + guard let existing = updaterLogFileCandidates.first(where: { fm.fileExists(atPath: $0.path) }), + let raw = try? String(contentsOf: existing, encoding: .utf8) else { + return "No updater log available yet." + } + let lines = raw + .split(whereSeparator: \.isNewline) + .suffix(8) + .map(String.init) + .map(Self.sanitizedDiagnosticSummary) + if lines.isEmpty { + return "No updater log entries yet." + } + return lines.joined(separator: "\n") + } + + func resetDiagnostics() { + clearInstallMessage() + errorMessage = nil + lastCheckedAt = nil + lastCheckResultSummary = "Never checked" + defaults.removeObject(forKey: Self.lastCheckedAtKey) + defaults.removeObject(forKey: Self.lastCheckSummaryKey) + defaults.removeObject(forKey: Self.pauseUntilKey) + defaults.removeObject(forKey: Self.consecutiveFailuresKey) + defaults.removeObject(forKey: Self.stagedUpdatePathKey) + } + func installUpdateNow() async { if let reason = installNowDisabledReason { installMessage = reason diff --git a/Neon Vision Editor/Core/EditorPerformanceMonitor.swift b/Neon Vision Editor/Core/EditorPerformanceMonitor.swift index cc756ad..d5a5abb 100644 --- a/Neon Vision Editor/Core/EditorPerformanceMonitor.swift +++ b/Neon Vision Editor/Core/EditorPerformanceMonitor.swift @@ -82,6 +82,10 @@ final class EditorPerformanceMonitor { return Array(decoded.suffix(clamped)) } + func clearRecentFileOpenEvents() { + defaults.removeObject(forKey: eventsDefaultsKey) + } + private func storeFileOpenEvent(_ event: FileOpenEvent) { var existing = recentFileOpenEvents(limit: maxEvents) existing.append(event) diff --git a/Neon Vision Editor/UI/ContentView+Actions.swift b/Neon Vision Editor/UI/ContentView+Actions.swift index 44c902a..810dc78 100644 --- a/Neon Vision Editor/UI/ContentView+Actions.swift +++ b/Neon Vision Editor/UI/ContentView+Actions.swift @@ -679,6 +679,9 @@ extension ContentView { presentUnsupportedFileAlert(for: url) return } + if viewModel.selectedTab?.fileURL?.standardizedFileURL == url.standardizedFileURL { + return + } if let existing = viewModel.tabs.first(where: { $0.fileURL?.standardizedFileURL == url.standardizedFileURL }) { viewModel.selectTab(id: existing.id) return diff --git a/Neon Vision Editor/UI/ContentView.swift b/Neon Vision Editor/UI/ContentView.swift index 367a522..1c35550 100644 --- a/Neon Vision Editor/UI/ContentView.swift +++ b/Neon Vision Editor/UI/ContentView.swift @@ -104,6 +104,21 @@ struct ContentView: View { case forceBlankDocument } + enum ProjectNavigatorPlacement: String, CaseIterable, Identifiable { + case leading + case trailing + + var id: String { rawValue } + } + + enum PerformancePreset: String, CaseIterable, Identifiable { + case balanced + case largeFiles + case battery + + var id: String { rawValue } + } + let startupBehavior: StartupBehavior init(startupBehavior: StartupBehavior = .standard) { @@ -113,9 +128,13 @@ struct ContentView: View { private enum EditorPerformanceThresholds { static let largeFileBytes = 12_000_000 static let largeFileBytesHTMLCSV = 4_000_000 + static let largeFileBytesMobile = 8_000_000 + static let largeFileBytesHTMLCSVMobile = 3_000_000 static let heavyFeatureUTF16Length = 450_000 static let largeFileLineBreaks = 40_000 static let largeFileLineBreaksHTMLCSV = 15_000 + static let largeFileLineBreaksMobile = 25_000 + static let largeFileLineBreaksHTMLCSVMobile = 10_000 } private static let completionSignposter = OSSignposter(subsystem: "h3p.Neon-Vision-Editor", category: "InlineCompletion") @@ -266,6 +285,8 @@ struct ContentView: View { @State var droppedFileLoadProgress: Double = 0 @State var droppedFileLoadLabel: String = "" @State var largeFileModeEnabled: Bool = false + @AppStorage("SettingsProjectNavigatorPlacement") var projectNavigatorPlacementRaw: String = ProjectNavigatorPlacement.trailing.rawValue + @AppStorage("SettingsPerformancePreset") var performancePresetRaw: String = PerformancePreset.balanced.rawValue #if os(iOS) @AppStorage("SettingsForceLargeFileMode") var forceLargeFileMode: Bool = false @AppStorage("SettingsShowKeyboardAccessoryBarIOS") var showKeyboardAccessoryBarIOS: Bool = false @@ -314,6 +335,14 @@ struct ContentView: View { } return trimmed } + + private var projectNavigatorPlacement: ProjectNavigatorPlacement { + ProjectNavigatorPlacement(rawValue: projectNavigatorPlacementRaw) ?? .trailing + } + + private var performancePreset: PerformancePreset { + PerformancePreset(rawValue: performancePresetRaw) ?? .balanced + } #if os(macOS) private enum MacTranslucencyMode: String { case subtle @@ -1484,12 +1513,31 @@ struct ContentView: View { let isHTMLLike = ["html", "htm", "xml", "svg", "xhtml"].contains(lowerLanguage) let isCSVLike = ["csv", "tsv"].contains(lowerLanguage) let useAggressiveThresholds = isHTMLLike || isCSVLike - let byteThreshold = useAggressiveThresholds + #if os(iOS) + var byteThreshold = useAggressiveThresholds + ? EditorPerformanceThresholds.largeFileBytesHTMLCSVMobile + : EditorPerformanceThresholds.largeFileBytesMobile + var lineThreshold = useAggressiveThresholds + ? EditorPerformanceThresholds.largeFileLineBreaksHTMLCSVMobile + : EditorPerformanceThresholds.largeFileLineBreaksMobile + #else + var byteThreshold = useAggressiveThresholds ? EditorPerformanceThresholds.largeFileBytesHTMLCSV : EditorPerformanceThresholds.largeFileBytes - let lineThreshold = useAggressiveThresholds + var lineThreshold = useAggressiveThresholds ? EditorPerformanceThresholds.largeFileLineBreaksHTMLCSV : EditorPerformanceThresholds.largeFileLineBreaks + #endif + switch performancePreset { + case .balanced: + break + case .largeFiles: + byteThreshold = max(1_000_000, Int(Double(byteThreshold) * 0.75)) + lineThreshold = max(5_000, Int(Double(lineThreshold) * 0.75)) + case .battery: + byteThreshold = max(750_000, Int(Double(byteThreshold) * 0.55)) + lineThreshold = max(3_000, Int(Double(lineThreshold) * 0.55)) + } let byteCount = text.utf8.count let exceedsByteThreshold = byteCount >= byteThreshold let exceedsLineThreshold: Bool = { @@ -3323,6 +3371,37 @@ struct ContentView: View { } ///MARK: - Main Editor Stack + @ViewBuilder + private var projectStructureSidebarPanel: some View { +#if os(macOS) + VStack(spacing: 0) { + Rectangle() + .fill(macChromeBackgroundStyle) + .frame(height: macTabBarStripHeight) + projectStructureSidebarBody + } + .frame(minWidth: 220, idealWidth: 260, maxWidth: 340) +#else + projectStructureSidebarBody + .frame(minWidth: 220, idealWidth: 260, maxWidth: 340) +#endif + } + + private var projectStructureSidebarBody: some View { + ProjectStructureSidebarView( + rootFolderURL: projectRootFolderURL, + nodes: projectTreeNodes, + selectedFileURL: viewModel.selectedTab?.fileURL, + showSupportedFilesOnly: showSupportedProjectFilesOnly, + translucentBackgroundEnabled: enableTranslucentWindow, + onOpenFile: { openFileFromToolbar() }, + onOpenFolder: { openProjectFolder() }, + onToggleSupportedFilesOnly: { showSupportedProjectFilesOnly = $0 }, + onOpenProjectFile: { openProjectFile(url: $0) }, + onRefreshTree: { refreshProjectTree() } + ) + } + var editorView: some View { @Bindable var bindableViewModel = viewModel let shouldThrottleFeatures = shouldThrottleHeavyEditorFeatures() @@ -3330,6 +3409,10 @@ struct ContentView: View { let effectiveScopeGuides = showScopeGuides && !shouldThrottleFeatures let effectiveScopeBackground = highlightScopeBackground && !shouldThrottleFeatures let content = HStack(spacing: 0) { + if showProjectStructureSidebar && projectNavigatorPlacement == .leading && !brainDumpLayoutEnabled { + projectStructureSidebarPanel + } + VStack(spacing: 0) { if !useIPhoneUnifiedTopHost && !brainDumpLayoutEnabled { tabBarView @@ -3415,41 +3498,8 @@ struct ContentView: View { .frame(minWidth: 280, idealWidth: 420, maxWidth: 680, maxHeight: .infinity) } - if showProjectStructureSidebar && !brainDumpLayoutEnabled { - #if os(macOS) - VStack(spacing: 0) { - Rectangle() - .fill(macChromeBackgroundStyle) - .frame(height: macTabBarStripHeight) - ProjectStructureSidebarView( - rootFolderURL: projectRootFolderURL, - nodes: projectTreeNodes, - selectedFileURL: viewModel.selectedTab?.fileURL, - showSupportedFilesOnly: showSupportedProjectFilesOnly, - translucentBackgroundEnabled: enableTranslucentWindow, - onOpenFile: { openFileFromToolbar() }, - onOpenFolder: { openProjectFolder() }, - onToggleSupportedFilesOnly: { showSupportedProjectFilesOnly = $0 }, - onOpenProjectFile: { openProjectFile(url: $0) }, - onRefreshTree: { refreshProjectTree() } - ) - } - .frame(minWidth: 220, idealWidth: 260, maxWidth: 340) - #else - ProjectStructureSidebarView( - rootFolderURL: projectRootFolderURL, - nodes: projectTreeNodes, - selectedFileURL: viewModel.selectedTab?.fileURL, - showSupportedFilesOnly: showSupportedProjectFilesOnly, - translucentBackgroundEnabled: enableTranslucentWindow, - onOpenFile: { openFileFromToolbar() }, - onOpenFolder: { openProjectFolder() }, - onToggleSupportedFilesOnly: { showSupportedProjectFilesOnly = $0 }, - onOpenProjectFile: { openProjectFile(url: $0) }, - onRefreshTree: { refreshProjectTree() } - ) - .frame(minWidth: 220, idealWidth: 260, maxWidth: 340) - #endif + if showProjectStructureSidebar && projectNavigatorPlacement == .trailing && !brainDumpLayoutEnabled { + projectStructureSidebarPanel } } .background( diff --git a/Neon Vision Editor/UI/NeonSettingsView.swift b/Neon Vision Editor/UI/NeonSettingsView.swift index 2790767..61e9d9f 100644 --- a/Neon Vision Editor/UI/NeonSettingsView.swift +++ b/Neon Vision Editor/UI/NeonSettingsView.swift @@ -51,6 +51,8 @@ struct NeonSettingsView: View { @AppStorage("SettingsAutoCloseBrackets") private var autoCloseBrackets: Bool = false @AppStorage("SettingsTrimTrailingWhitespace") private var trimTrailingWhitespace: Bool = false @AppStorage("SettingsTrimWhitespaceForSyntaxDetection") private var trimWhitespaceForSyntaxDetection: Bool = false + @AppStorage("SettingsProjectNavigatorPlacement") private var projectNavigatorPlacementRaw: String = ContentView.ProjectNavigatorPlacement.trailing.rawValue + @AppStorage("SettingsPerformancePreset") private var performancePresetRaw: String = ContentView.PerformancePreset.balanced.rawValue @AppStorage("SettingsCompletionEnabled") private var completionEnabled: Bool = false @AppStorage("SettingsCompletionFromDocument") private var completionFromDocument: Bool = false @@ -990,6 +992,34 @@ struct NeonSettingsView: View { } } + settingsCardSection( + title: "Layout", + icon: "sidebar.left", + emphasis: .secondary + ) { + Picker("Project Navigator Position", selection: $projectNavigatorPlacementRaw) { + Text("Left").tag(ContentView.ProjectNavigatorPlacement.leading.rawValue) + Text("Right").tag(ContentView.ProjectNavigatorPlacement.trailing.rawValue) + } + .pickerStyle(.segmented) + } + + settingsCardSection( + title: "Performance", + icon: "speedometer", + emphasis: .secondary + ) { + Picker("Preset", selection: $performancePresetRaw) { + Text("Balanced").tag(ContentView.PerformancePreset.balanced.rawValue) + Text("Large Files").tag(ContentView.PerformancePreset.largeFiles.rawValue) + Text("Battery").tag(ContentView.PerformancePreset.battery.rawValue) + } + .pickerStyle(.segmented) + Text("Balanced keeps default behavior. Large Files and Battery enter performance mode earlier.") + .font(Typography.footnote) + .foregroundStyle(.secondary) + } + settingsCardSection( title: "Editing", icon: "keyboard", @@ -1057,6 +1087,36 @@ struct NeonSettingsView: View { Divider() + VStack(alignment: .leading, spacing: UI.space10) { + Text("Layout") + .font(Typography.sectionHeadline) + Picker("Project Navigator Position", selection: $projectNavigatorPlacementRaw) { + Text("Left").tag(ContentView.ProjectNavigatorPlacement.leading.rawValue) + Text("Right").tag(ContentView.ProjectNavigatorPlacement.trailing.rawValue) + } + .pickerStyle(.segmented) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Divider() + + VStack(alignment: .leading, spacing: UI.space10) { + Text("Performance") + .font(Typography.sectionHeadline) + Picker("Preset", selection: $performancePresetRaw) { + Text("Balanced").tag(ContentView.PerformancePreset.balanced.rawValue) + Text("Large Files").tag(ContentView.PerformancePreset.largeFiles.rawValue) + Text("Battery").tag(ContentView.PerformancePreset.battery.rawValue) + } + .pickerStyle(.segmented) + Text("Balanced keeps default behavior. Large Files and Battery enter performance mode earlier.") + .font(Typography.footnote) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Divider() + VStack(alignment: .leading, spacing: UI.space10) { Text("Editing") .font(Typography.sectionHeadline) @@ -1702,6 +1762,22 @@ struct NeonSettingsView: View { .font(Typography.footnote) .foregroundStyle(.orange) } + Text("Staged update: \(appUpdateManager.stagedUpdateVersionSummary)") + .font(Typography.footnote) + .foregroundStyle(.secondary) + Text("Last install attempt: \(appUpdateManager.lastInstallAttemptSummary)") + .font(Typography.footnote) + .foregroundStyle(.secondary) + + Text("Recent updater log") + .font(.subheadline.weight(.semibold)) + ScrollView { + Text(appUpdateManager.recentLogSnippet) + .font(Typography.monoBody) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .frame(maxHeight: 140) Divider() @@ -1738,6 +1814,11 @@ struct NeonSettingsView: View { copyDiagnosticsToClipboard() } .buttonStyle(.borderedProminent) + Button("Clear Diagnostics") { + appUpdateManager.resetDiagnostics() + EditorPerformanceMonitor.shared.clearRecentFileOpenEvents() + diagnosticsCopyStatus = "Cleared" + } if !diagnosticsCopyStatus.isEmpty { Text(diagnosticsCopyStatus) .font(Typography.footnote) @@ -1754,10 +1835,14 @@ struct NeonSettingsView: View { lines.append("Timestamp: \(Date().formatted(date: .abbreviated, time: .shortened))") lines.append("Updater.lastCheckResult: \(AppUpdateManager.sanitizedDiagnosticSummary(appUpdateManager.lastCheckResultSummary))") lines.append("Updater.lastCheckedAt: \(appUpdateManager.lastCheckedAt?.formatted(date: .abbreviated, time: .shortened) ?? "never")") + lines.append("Updater.stagedVersion: \(appUpdateManager.stagedUpdateVersionSummary)") + lines.append("Updater.lastInstallAttempt: \(AppUpdateManager.sanitizedDiagnosticSummary(appUpdateManager.lastInstallAttemptSummary))") if let pausedUntil = appUpdateManager.pausedUntil, pausedUntil > Date() { lines.append("Updater.pauseUntil: \(pausedUntil.formatted(date: .abbreviated, time: .shortened))") } lines.append("Updater.consecutiveFailures: \(appUpdateManager.consecutiveFailureCount)") + lines.append("Updater.logSnippet:") + lines.append(appUpdateManager.recentLogSnippet) lines.append("FileOpenEvents.count: \(events.count)") for event in events { lines.append(