Implement core 0.5.2 milestone items

This commit is contained in:
h3p 2026-03-09 13:54:26 +01:00
parent 111dc5fed4
commit 9d8cc44922
7 changed files with 236 additions and 39 deletions

View file

@ -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

View file

@ -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;

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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(

View file

@ -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(