Prepare v0.5.1 release updates

This commit is contained in:
h3p 2026-03-08 15:31:01 +01:00
parent 41a3d1798c
commit 3a8c13806c
14 changed files with 575 additions and 39 deletions

View file

@ -6,13 +6,30 @@ The format follows *Keep a Changelog*. Versions use semantic versioning with pre
## [Unreleased]
## [v0.5.1] - 2026-03-08
### Added
- Added bulk `Close All Tabs` actions to toolbar surfaces (macOS, iOS, iPadOS), including a confirmation step before closing.
- Added project-structure quick actions to expand all folders or collapse all folders in one step.
- Added six vivid neon syntax themes with distinct color profiles: `Neon Voltage`, `Laserwave`, `Cyber Lime`, `Plasma Storm`, `Inferno Neon`, and `Ultraviolet Flux`.
- Added a lock-safe cross-platform build matrix helper script (`scripts/ci/build_platform_matrix.sh`) to run macOS + iOS Simulator + iPad Simulator builds sequentially.
- Added iPhone Markdown preview as a bottom sheet with toolbar toggle and resizable detents for Apple-guideline-compliant height control.
- Added unsupported-file safety handling across project sidebar, open/import flows, and user-facing unsupported-file alerts instead of crash paths.
- Added a project-sidebar switch to show only supported files (enabled by default).
- Added SVG (`.svg`) editor file support with XML language mapping and syntax-highlighting path reuse.
### Improved
- Improved Markdown preview stability by preserving relative scroll position during preview refreshes.
- Improved Markdown preview behavior for very large files by using a safe plain-text fallback with explicit status messaging instead of full HTML conversion.
- Improved neon syntax vibrancy consistency by extending the raw-neon adjustment profile to additional high-intensity neon themes.
- Improved contributor guidance with a documented lock-safe platform build verification command in `README.md`.
- Improved iPhone markdown preview sheet header density by using inline navigation-title mode to align title height with the `Done/Fertig` action row.
### Fixed
- Fixed diagnostics export safety by redacting token-like updater status fragments before copying.
- Fixed Markdown regression coverage with new tests for Claude-style mixed-content Markdown and code-fence matching behavior.
- Fixed accidental destructive tab-bulk-close behavior by requiring explicit user confirmation before closing all tabs.
- Fixed missing localization for new close-all confirmation/actions by adding English and German strings.
## [v0.5.0] - 2026-03-06

View file

@ -361,7 +361,7 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 433;
CURRENT_PROJECT_VERSION = 434;
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 = 433;
CURRENT_PROJECT_VERSION = 434;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES;

View file

@ -28,6 +28,7 @@ public struct LanguageDetector {
"yaml": "yaml",
"yml": "yaml",
"xml": "xml",
"svg": "xml",
"plist": "xml",
"sql": "sql",
"log": "log",

View file

@ -3,6 +3,9 @@ import Observation
import UniformTypeIdentifiers
import Foundation
import OSLog
#if os(macOS)
import AppKit
#endif
#if canImport(UIKit)
import UIKit
#endif
@ -802,6 +805,7 @@ class EditorViewModel {
"yaml": "yaml",
"yml": "yaml",
"xml": "xml",
"svg": "xml",
"sql": "sql",
"log": "log",
"vim": "vim",
@ -1182,7 +1186,9 @@ class EditorViewModel {
if panel.runModal() == .OK {
let urls = panel.urls
for url in urls {
openFile(url: url)
if !openFile(url: url) {
presentUnsupportedFileAlertOnMac(for: url)
}
}
}
#else
@ -1192,8 +1198,13 @@ class EditorViewModel {
}
// Loads a file into a new tab unless the file is already open.
func openFile(url: URL) {
if focusTabIfOpen(for: url) { return }
@discardableResult
func openFile(url: URL) -> Bool {
guard Self.isSupportedEditorFileURL(url) else {
debugLog("Unsupported file type skipped: \(url.lastPathComponent)")
return false
}
if focusTabIfOpen(for: url) { return true }
let extLangHint = LanguageDetector.shared.preferredLanguage(for: url) ?? languageMap[url.pathExtension.lowercased()]
let fileSize = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
let isLargeCandidate = fileSize >= EditorLoadHelper.largeFileCandidateByteThreshold
@ -1222,8 +1233,55 @@ class EditorViewModel {
await self.markTabLoadFailed(tabID: tabID)
}
}
return true
}
nonisolated static func isSupportedEditorFileURL(_ url: URL) -> Bool {
if url.hasDirectoryPath { return false }
let fileName = url.lastPathComponent.lowercased()
let ext = url.pathExtension.lowercased()
if ext.isEmpty {
let supportedDotfiles: Set<String> = [
".zshrc", ".zprofile", ".zlogin", ".zlogout",
".bashrc", ".bash_profile", ".bash_login", ".bash_logout",
".profile", ".vimrc", ".env", ".envrc", ".gitconfig"
]
return supportedDotfiles.contains(fileName) || fileName.hasPrefix(".env")
}
let knownSupportedExtensions: Set<String> = [
"swift", "py", "pyi", "js", "mjs", "cjs", "ts", "tsx", "php", "phtml",
"csv", "tsv", "txt", "toml", "ini", "yaml", "yml", "xml", "svg", "plist", "sql",
"log", "vim", "ipynb", "java", "kt", "kts", "go", "rb", "rs", "ps1", "psm1",
"html", "htm", "ee", "exp", "tmpl", "css", "c", "cpp", "cc", "hpp", "hh", "h",
"m", "mm", "cs", "json", "jsonc", "json5", "md", "markdown", "env", "proto",
"graphql", "gql", "rst", "conf", "nginx", "cob", "cbl", "cobol", "sh", "bash", "zsh"
]
if knownSupportedExtensions.contains(ext) {
return true
}
guard let type = UTType(filenameExtension: ext) else { return false }
if type.conforms(to: .text) || type.conforms(to: .plainText) || type.conforms(to: .sourceCode) {
return true
}
return false
}
#if os(macOS)
private func presentUnsupportedFileAlertOnMac(for url: URL) {
let title = NSLocalizedString("Cant Open File", comment: "Unsupported file alert title")
let format = NSLocalizedString("The file \"%@\" is not supported and cant be opened.", comment: "Unsupported file alert message")
let alert = NSAlert()
alert.messageText = title
alert.informativeText = String(format: format, url.lastPathComponent)
alert.alertStyle = .warning
alert.addButton(withTitle: NSLocalizedString("OK", comment: "Alert confirmation button"))
alert.runModal()
}
#endif
private nonisolated static func contentFingerprintValue(_ text: String) -> UInt64 {
var hasher = Hasher()
hasher.combine(text)

View file

@ -112,24 +112,41 @@ extension ContentView {
case .success(let urls):
guard !urls.isEmpty else { return }
var openedCount = 0
var unsupportedCount = 0
var openedNames: [String] = []
for url in urls {
viewModel.openFile(url: url)
openedCount += 1
openedNames.append(url.lastPathComponent)
if viewModel.openFile(url: url) {
openedCount += 1
openedNames.append(url.lastPathComponent)
} else {
unsupportedCount += 1
presentUnsupportedFileAlert(for: url)
}
}
guard openedCount > 0 else {
findStatusMessage = "Open failed: selected files are no longer available."
if unsupportedCount > 0 {
findStatusMessage = "Open failed: unsupported file type."
} else {
findStatusMessage = "Open failed: selected files are no longer available."
}
recordDiagnostic("iOS import failed: no valid files in selection")
return
}
if openedCount == 1, let name = openedNames.first {
findStatusMessage = "Opened \(name)"
if unsupportedCount > 0 {
findStatusMessage = "Opened \(name) (\(unsupportedCount) unsupported ignored)"
} else {
findStatusMessage = "Opened \(name)"
}
} else {
findStatusMessage = "Opened \(openedCount) files"
if unsupportedCount > 0 {
findStatusMessage = "Opened \(openedCount) files (\(unsupportedCount) unsupported ignored)"
} else {
findStatusMessage = "Opened \(openedCount) files"
}
}
recordDiagnostic("iOS import success count: \(openedCount)")
case .failure(let error):
@ -242,10 +259,31 @@ extension ContentView {
if UIDevice.current.userInterfaceIdiom == .pad && nextValue {
showProjectStructureSidebar = false
showCompactProjectSidebarSheet = false
} else if UIDevice.current.userInterfaceIdiom == .phone && nextValue {
dismissKeyboard()
}
#endif
}
func closeAllTabsFromToolbar() {
let dirtyTabIDs = viewModel.tabs.filter(\.isDirty).map(\.id)
for tabID in dirtyTabIDs {
guard viewModel.tabs.contains(where: { $0.id == tabID }) else { continue }
viewModel.saveFile(tabID: tabID)
}
let tabIDsToClose = viewModel.tabs.map(\.id)
for tabID in tabIDsToClose {
guard viewModel.tabs.contains(where: { $0.id == tabID }) else { continue }
viewModel.closeTab(tabID: tabID)
}
}
func requestCloseAllTabsFromToolbar() {
guard !viewModel.tabs.isEmpty else { return }
showCloseAllTabsDialog = true
}
func dismissKeyboard() {
#if os(iOS)
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
@ -624,8 +662,9 @@ extension ContentView {
guard let root = projectRootFolderURL else { return }
projectTreeRefreshGeneration &+= 1
let generation = projectTreeRefreshGeneration
let supportedOnly = showSupportedProjectFilesOnly
DispatchQueue.global(qos: .utility).async {
let nodes = Self.buildProjectTree(at: root)
let nodes = Self.buildProjectTree(at: root, supportedOnly: supportedOnly)
DispatchQueue.main.async {
guard generation == projectTreeRefreshGeneration else { return }
guard projectRootFolderURL?.standardizedFileURL == root.standardizedFileURL else { return }
@ -636,21 +675,27 @@ extension ContentView {
}
func openProjectFile(url: URL) {
guard EditorViewModel.isSupportedEditorFileURL(url) else {
presentUnsupportedFileAlert(for: url)
return
}
if let existing = viewModel.tabs.first(where: { $0.fileURL?.standardizedFileURL == url.standardizedFileURL }) {
viewModel.selectTab(id: existing.id)
return
}
viewModel.openFile(url: url)
if !viewModel.openFile(url: url) {
presentUnsupportedFileAlert(for: url)
}
}
private nonisolated static func buildProjectTree(at root: URL) -> [ProjectTreeNode] {
private nonisolated static func buildProjectTree(at root: URL, supportedOnly: Bool) -> [ProjectTreeNode] {
var isDir: ObjCBool = false
guard FileManager.default.fileExists(atPath: root.path, isDirectory: &isDir), isDir.boolValue else { return [] }
return readChildren(of: root, recursive: true)
return readChildren(of: root, recursive: true, supportedOnly: supportedOnly)
}
func loadProjectTreeChildren(for directory: URL) -> [ProjectTreeNode] {
Self.readChildren(of: directory, recursive: false)
Self.readChildren(of: directory, recursive: false, supportedOnly: showSupportedProjectFilesOnly)
}
func setProjectFolder(_ folderURL: URL) {
@ -704,7 +749,7 @@ extension ContentView {
}
}
private nonisolated static func readChildren(of directory: URL, recursive: Bool) -> [ProjectTreeNode] {
private nonisolated static func readChildren(of directory: URL, recursive: Bool, supportedOnly: Bool) -> [ProjectTreeNode] {
if Task.isCancelled { return [] }
let fm = FileManager.default
let keys: [URLResourceKey] = [.isDirectoryKey, .isHiddenKey, .nameKey]
@ -719,7 +764,10 @@ extension ContentView {
guard let values = try? url.resourceValues(forKeys: Set(keys)) else { continue }
if values.isHidden == true { continue }
let isDirectory = values.isDirectory == true
let children = (isDirectory && recursive) ? readChildren(of: url, recursive: true) : []
if !isDirectory && supportedOnly && !EditorViewModel.isSupportedEditorFileURL(url) {
continue
}
let children = (isDirectory && recursive) ? readChildren(of: url, recursive: true, supportedOnly: supportedOnly) : []
nodes.append(
ProjectTreeNode(
url: url,
@ -731,6 +779,12 @@ extension ContentView {
return nodes
}
private func presentUnsupportedFileAlert(for url: URL) {
unsupportedFileName = url.lastPathComponent
findStatusMessage = "Unsupported file type."
showUnsupportedFileAlert = true
}
static func projectFileURLs(from nodes: [ProjectTreeNode]) -> [URL] {
var results: [URL] = []
var stack = nodes

View file

@ -106,6 +106,7 @@ extension ContentView {
case openFile
case undo
case newTab
case closeAllTabs
case saveFile
case markdownPreview
case fontDecrease
@ -130,6 +131,7 @@ extension ContentView {
.openFile,
.undo,
.newTab,
.closeAllTabs,
.saveFile,
.markdownPreview,
.fontDecrease,
@ -178,7 +180,7 @@ extension ContentView {
}
private var iPadAlwaysVisibleActions: [IPadToolbarAction] {
[.openFile, .newTab, .saveFile, .findReplace, .settings]
[.openFile, .newTab, .closeAllTabs, .saveFile, .findReplace, .settings]
}
private var iPadPromotedActionSlotCount: Int {
@ -341,6 +343,17 @@ extension ContentView {
.keyboardShortcut("s", modifiers: .command)
}
@ViewBuilder
private var closeAllTabsControl: some View {
Button(action: { requestCloseAllTabsFromToolbar() }) {
Image(systemName: "xmark.square")
}
.disabled(viewModel.tabs.isEmpty)
.help("Close All Tabs")
.accessibilityLabel("Close all tabs")
.accessibilityHint("Closes every open tab")
}
@ViewBuilder
private var fontDecreaseControl: some View {
Button(action: { adjustEditorFontSize(-1) }) {
@ -473,6 +486,7 @@ extension ContentView {
case .openFile: openFileControl
case .undo: undoControl
case .newTab: newTabControl
case .closeAllTabs: closeAllTabsControl
case .saveFile: saveFileControl
case .markdownPreview: markdownPreviewControl
case .fontDecrease: fontDecreaseControl
@ -512,6 +526,11 @@ extension ContentView {
Button(action: { viewModel.addNewTab() }) {
Label("New Tab", systemImage: "plus.square.on.square")
}
case .closeAllTabs:
Button(action: { requestCloseAllTabsFromToolbar() }) {
Label("Close All Tabs", systemImage: "xmark.square")
}
.disabled(viewModel.tabs.isEmpty)
case .saveFile:
Button(action: { saveCurrentTabFromToolbar() }) {
Label("Save File", systemImage: "square.and.arrow.down")
@ -660,6 +679,19 @@ extension ContentView {
.disabled(viewModel.selectedTab == nil)
.keyboardShortcut("s", modifiers: .command)
Button(action: { toggleMarkdownPreviewFromToolbar() }) {
Label(
"Markdown Preview",
systemImage: showMarkdownPreviewPane ? "doc.richtext.fill" : "doc.richtext"
)
}
.disabled(currentLanguage != "markdown")
Button(action: { requestCloseAllTabsFromToolbar() }) {
Label("Close All Tabs", systemImage: "xmark.square")
}
.disabled(viewModel.tabs.isEmpty)
Button(action: { saveCurrentTabAsFromToolbar() }) {
Label("Save As…", systemImage: "square.and.arrow.down.on.square")
}
@ -915,6 +947,12 @@ extension ContentView {
}
.help("New Tab (Cmd+T)")
Button(action: { requestCloseAllTabsFromToolbar() }) {
Label("Close All Tabs", systemImage: "xmark.square")
.foregroundStyle(NeonUIStyle.accentBlue)
}
.help("Close All Tabs")
Button(action: {
saveCurrentTabFromToolbar()
}) {

View file

@ -206,6 +206,7 @@ struct ContentView: View {
#endif
#if os(iOS)
@State private var previousKeyboardAccessoryVisibility: Bool? = nil
@State private var markdownPreviewSheetDetent: PresentationDetent = .medium
#endif
#if os(macOS)
@AppStorage("SettingsMacTranslucencyMode") private var macTranslucencyModeRaw: String = "balanced"
@ -227,18 +228,22 @@ struct ContentView: View {
@State var projectRootFolderURL: URL? = nil
@State var projectTreeNodes: [ProjectTreeNode] = []
@State var projectTreeRefreshGeneration: Int = 0
@AppStorage("SettingsShowSupportedProjectFilesOnly") var showSupportedProjectFilesOnly: Bool = true
@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
@State var showUnsavedCloseDialog: Bool = false
@State var showCloseAllTabsDialog: Bool = false
@State private var showExternalConflictDialog: Bool = false
@State private var showExternalConflictCompareSheet: Bool = false
@State private var externalConflictCompareSnapshot: EditorViewModel.ExternalFileComparisonSnapshot?
@State var showClearEditorConfirmDialog: Bool = false
@State var showIOSFileImporter: Bool = false
@State var showIOSFileExporter: Bool = false
@State var showUnsupportedFileAlert: Bool = false
@State var unsupportedFileName: String = ""
@State var iosExportDocument: PlainTextDocument = PlainTextDocument(text: "")
@State var iosExportFilename: String = "Untitled.txt"
@State var iosExportTabID: UUID? = nil
@ -381,6 +386,14 @@ struct ContentView: View {
}
var canShowMarkdownPreviewPane: Bool {
#if os(iOS)
true
#else
true
#endif
}
private var canShowMarkdownPreviewSplitPane: Bool {
#if os(iOS)
canShowMarkdownPreviewOnCurrentDevice
#else
@ -388,6 +401,26 @@ struct ContentView: View {
#endif
}
#if os(iOS)
private var shouldPresentMarkdownPreviewSheetOnIPhone: Bool {
UIDevice.current.userInterfaceIdiom == .phone &&
showMarkdownPreviewPane &&
currentLanguage == "markdown" &&
!brainDumpLayoutEnabled
}
private var markdownPreviewSheetPresentationBinding: Binding<Bool> {
Binding(
get: { shouldPresentMarkdownPreviewSheetOnIPhone },
set: { isPresented in
if !isPresented {
showMarkdownPreviewPane = false
}
}
)
}
#endif
private var settingsSheetDetents: Set<PresentationDetent> {
#if os(iOS)
if UIDevice.current.userInterfaceIdiom == .pad {
@ -1836,6 +1869,9 @@ struct ContentView: View {
.onChange(of: showProjectStructureSidebar) { _, _ in
persistSessionIfReady()
}
.onChange(of: showSupportedProjectFilesOnly) { _, _ in
refreshProjectTree()
}
.onChange(of: showMarkdownPreviewPane) { _, _ in
persistSessionIfReady()
}
@ -2031,9 +2067,11 @@ struct ContentView: View {
rootFolderURL: contentView.projectRootFolderURL,
nodes: contentView.projectTreeNodes,
selectedFileURL: contentView.viewModel.selectedTab?.fileURL,
showSupportedFilesOnly: contentView.showSupportedProjectFilesOnly,
translucentBackgroundEnabled: false,
onOpenFile: { contentView.openFileFromToolbar() },
onOpenFolder: { contentView.openProjectFolder() },
onToggleSupportedFilesOnly: { contentView.showSupportedProjectFilesOnly = $0 },
onOpenProjectFile: { contentView.openProjectFile(url: $0) },
onRefreshTree: { contentView.refreshProjectTree() }
)
@ -2048,6 +2086,23 @@ struct ContentView: View {
}
.presentationDetents([.medium, .large])
}
.sheet(isPresented: contentView.markdownPreviewSheetPresentationBinding) {
NavigationStack {
contentView.markdownPreviewPane
.navigationTitle("Markdown Preview")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
contentView.showMarkdownPreviewPane = false
}
}
}
}
.presentationDetents([.fraction(0.35), .medium, .large], selection: contentView.$markdownPreviewSheetDetent)
.presentationDragIndicator(.visible)
.presentationContentInteraction(.scrolls)
}
#endif
#if canImport(UIKit)
.sheet(isPresented: contentView.$showProjectFolderPicker) {
@ -2124,6 +2179,12 @@ struct ContentView: View {
Text("This file has unsaved changes.")
}
}
.confirmationDialog("Are you sure you want to close all tabs?", isPresented: contentView.$showCloseAllTabsDialog, titleVisibility: .visible) {
Button("Close All Tabs", role: .destructive) {
contentView.closeAllTabsFromToolbar()
}
Button("Cancel", role: .cancel) { }
}
.confirmationDialog("File changed on disk", isPresented: contentView.$showExternalConflictDialog, titleVisibility: .visible) {
if let conflict = contentView.viewModel.pendingExternalFileConflict {
Button("Reload from Disk", role: .destructive) {
@ -2207,6 +2268,17 @@ struct ContentView: View {
} message: {
Text("This will remove all text in the current editor.")
}
.alert("Cant Open File", isPresented: contentView.$showUnsupportedFileAlert) {
Button("OK", role: .cancel) { }
} message: {
Text(String(
format: NSLocalizedString(
"The file \"%@\" is not supported and cant be opened.",
comment: "Unsupported file alert message"
),
contentView.unsupportedFileName
))
}
#if canImport(UIKit)
.fileImporter(
isPresented: contentView.$showIOSFileImporter,
@ -3337,7 +3409,7 @@ struct ContentView: View {
alignment: brainDumpLayoutEnabled ? .top : .topLeading
)
if canShowMarkdownPreviewPane && showMarkdownPreviewPane && currentLanguage == "markdown" && !brainDumpLayoutEnabled {
if canShowMarkdownPreviewSplitPane && showMarkdownPreviewPane && currentLanguage == "markdown" && !brainDumpLayoutEnabled {
Divider()
markdownPreviewPane
.frame(minWidth: 280, idealWidth: 420, maxWidth: 680, maxHeight: .infinity)
@ -3353,9 +3425,11 @@ struct ContentView: View {
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() }
)
@ -3366,9 +3440,11 @@ struct ContentView: View {
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() }
)
@ -3432,7 +3508,7 @@ struct ContentView: View {
)
}
.onChange(of: horizontalSizeClass) { _, newClass in
if newClass != .regular && showMarkdownPreviewPane {
if UIDevice.current.userInterfaceIdiom == .pad && newClass != .regular && showMarkdownPreviewPane {
showMarkdownPreviewPane = false
}
}

View file

@ -337,12 +337,12 @@ struct WelcomeTourView: View {
private let pages: [TourPage] = [
TourPage(
title: "Whats New in This Release",
subtitle: "Major changes since v0.4.34:",
subtitle: "Major changes since v0.5.0:",
bullets: [
"Added updater staging hardening with retry/fallback behavior and staged-bundle integrity checks.",
"Added explicit accessibility labels/hints for key toolbar actions and updater log/progress controls.",
"Added a 0.5.0 quality roadmap milestone with focused issues for updater reliability, accessibility, and release gating.",
"Improved CSV handling by enabling fast syntax profile earlier and for long-line CSV files to reduce freeze risk."
"Added bulk `Close All Tabs` actions to toolbar surfaces (macOS, iOS, iPadOS), including a confirmation step before closing.",
"Added project-structure quick actions to expand all folders or collapse all folders in one step.",
"Added six vivid neon syntax themes with distinct color profiles: `Neon Voltage`, `Laserwave`, `Cyber Lime`, `Plasma Storm`, `Inferno Neon`, and `Ultraviolet Flux`.",
"Added a lock-safe cross-platform build matrix helper script (`scripts/ci/build_platform_matrix.sh`) to run macOS + iOS Simulator + iPad Simulator builds sequentially."
],
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)],

View file

@ -358,9 +358,11 @@ struct ProjectStructureSidebarView: View {
let rootFolderURL: URL?
let nodes: [ProjectTreeNode]
let selectedFileURL: URL?
let showSupportedFilesOnly: Bool
let translucentBackgroundEnabled: Bool
let onOpenFile: () -> Void
let onOpenFolder: () -> Void
let onToggleSupportedFilesOnly: (Bool) -> Void
let onOpenProjectFile: (URL) -> Void
let onRefreshTree: () -> Void
@State private var expandedDirectories: Set<String> = []
@ -392,6 +394,30 @@ struct ProjectStructureSidebarView: View {
}
.buttonStyle(.borderless)
.help("Refresh Folder Tree")
Menu {
Button {
onToggleSupportedFilesOnly(!showSupportedFilesOnly)
} label: {
Label(
"Show Supported Files Only",
systemImage: showSupportedFilesOnly ? "checkmark.circle.fill" : "circle"
)
}
Divider()
Button("Expand All") {
expandAllDirectories()
}
Button("Collapse All") {
collapseAllDirectories()
}
} label: {
Image(systemName: "arrow.up.arrow.down.circle")
}
.buttonStyle(.borderless)
.help("Expand or Collapse All")
.accessibilityLabel("Expand or collapse all folders")
.accessibilityHint("Expands or collapses all folders in the project tree")
}
.padding(.horizontal, 10)
.padding(.top, 10)
@ -533,6 +559,23 @@ struct ProjectStructureSidebarView: View {
#endif
}
private func expandAllDirectories() {
expandedDirectories = allDirectoryNodeIDs(in: nodes)
}
private func collapseAllDirectories() {
expandedDirectories.removeAll()
}
private func allDirectoryNodeIDs(in treeNodes: [ProjectTreeNode]) -> Set<String> {
var result: Set<String> = []
for node in treeNodes where node.isDirectory {
result.insert(node.id)
result.formUnion(allDirectoryNodeIDs(in: node.children))
}
return result
}
private func projectNodeView(_ node: ProjectTreeNode, level: Int) -> AnyView {
if node.isDirectory {
return AnyView(

View file

@ -155,6 +155,12 @@ private func adjustedSyntaxColor(
let editorThemeNames: [String] = [
"Neon Glow",
"Neon Flow",
"Neon Voltage",
"Laserwave",
"Cyber Lime",
"Plasma Storm",
"Inferno Neon",
"Ultraviolet Flux",
"Custom",
"Dracula",
"One Dark Pro",
@ -221,6 +227,90 @@ private func paletteForThemeName(_ name: String, defaults: UserDefaults) -> Them
property: Color(red: 0.92, green: 0.05, blue: 0.91),
builtin: Color(red: 0.96, green: 0.20, blue: 0.04)
)
case "Neon Voltage":
return ThemePalette(
text: Color(red: 0.95, green: 0.98, blue: 1.00),
background: Color(red: 0.04, green: 0.06, blue: 0.09),
cursor: Color(red: 0.00, green: 0.92, blue: 1.00),
selection: Color(red: 0.12, green: 0.20, blue: 0.30),
keyword: Color(red: 0.00, green: 0.92, blue: 1.00),
string: Color(red: 0.35, green: 1.00, blue: 0.72),
number: Color(red: 1.00, green: 0.72, blue: 0.10),
comment: Color(red: 0.48, green: 0.56, blue: 0.68),
type: Color(red: 0.74, green: 0.52, blue: 1.00),
property: Color(red: 0.32, green: 0.88, blue: 1.00),
builtin: Color(red: 1.00, green: 0.34, blue: 0.74)
)
case "Laserwave":
return ThemePalette(
text: Color(red: 0.96, green: 0.93, blue: 1.00),
background: Color(red: 0.10, green: 0.05, blue: 0.14),
cursor: Color(red: 0.98, green: 0.24, blue: 0.88),
selection: Color(red: 0.25, green: 0.15, blue: 0.34),
keyword: Color(red: 1.00, green: 0.28, blue: 0.86),
string: Color(red: 0.22, green: 0.94, blue: 1.00),
number: Color(red: 1.00, green: 0.60, blue: 0.30),
comment: Color(red: 0.60, green: 0.52, blue: 0.72),
type: Color(red: 0.65, green: 0.72, blue: 1.00),
property: Color(red: 0.36, green: 0.98, blue: 0.84),
builtin: Color(red: 1.00, green: 0.42, blue: 0.56)
)
case "Cyber Lime":
return ThemePalette(
text: Color(red: 0.94, green: 0.98, blue: 0.92),
background: Color(red: 0.07, green: 0.09, blue: 0.06),
cursor: Color(red: 0.68, green: 1.00, blue: 0.20),
selection: Color(red: 0.18, green: 0.25, blue: 0.14),
keyword: Color(red: 0.68, green: 1.00, blue: 0.20),
string: Color(red: 0.20, green: 1.00, blue: 0.78),
number: Color(red: 1.00, green: 0.86, blue: 0.22),
comment: Color(red: 0.54, green: 0.62, blue: 0.50),
type: Color(red: 0.48, green: 0.86, blue: 1.00),
property: Color(red: 0.88, green: 1.00, blue: 0.36),
builtin: Color(red: 1.00, green: 0.48, blue: 0.40)
)
case "Plasma Storm":
return ThemePalette(
text: Color(red: 0.95, green: 0.95, blue: 1.00),
background: Color(red: 0.06, green: 0.05, blue: 0.10),
cursor: Color(red: 0.54, green: 0.44, blue: 1.00),
selection: Color(red: 0.16, green: 0.12, blue: 0.30),
keyword: Color(red: 0.58, green: 0.46, blue: 1.00),
string: Color(red: 0.24, green: 0.90, blue: 1.00),
number: Color(red: 1.00, green: 0.52, blue: 0.20),
comment: Color(red: 0.54, green: 0.50, blue: 0.68),
type: Color(red: 0.80, green: 0.54, blue: 1.00),
property: Color(red: 0.66, green: 0.78, blue: 1.00),
builtin: Color(red: 1.00, green: 0.30, blue: 0.66)
)
case "Inferno Neon":
return ThemePalette(
text: Color(red: 1.00, green: 0.94, blue: 0.90),
background: Color(red: 0.11, green: 0.05, blue: 0.03),
cursor: Color(red: 1.00, green: 0.46, blue: 0.08),
selection: Color(red: 0.28, green: 0.12, blue: 0.08),
keyword: Color(red: 1.00, green: 0.38, blue: 0.16),
string: Color(red: 1.00, green: 0.74, blue: 0.24),
number: Color(red: 1.00, green: 0.26, blue: 0.48),
comment: Color(red: 0.70, green: 0.54, blue: 0.46),
type: Color(red: 1.00, green: 0.56, blue: 0.20),
property: Color(red: 1.00, green: 0.70, blue: 0.32),
builtin: Color(red: 1.00, green: 0.18, blue: 0.30)
)
case "Ultraviolet Flux":
return ThemePalette(
text: Color(red: 0.96, green: 0.93, blue: 1.00),
background: Color(red: 0.08, green: 0.04, blue: 0.12),
cursor: Color(red: 0.84, green: 0.36, blue: 1.00),
selection: Color(red: 0.21, green: 0.12, blue: 0.30),
keyword: Color(red: 0.84, green: 0.36, blue: 1.00),
string: Color(red: 0.32, green: 0.98, blue: 0.96),
number: Color(red: 1.00, green: 0.62, blue: 0.24),
comment: Color(red: 0.60, green: 0.52, blue: 0.72),
type: Color(red: 1.00, green: 0.38, blue: 0.86),
property: Color(red: 0.68, green: 0.72, blue: 1.00),
builtin: Color(red: 1.00, green: 0.28, blue: 0.56)
)
case "Dracula":
return ThemePalette(
text: Color(red: 0.97, green: 0.97, blue: 0.95),
@ -508,7 +598,18 @@ func currentEditorTheme(colorScheme: ColorScheme) -> EditorTheme {
return Color(red: 0.90, green: 0.90, blue: 0.90)
}()
let profile: SyntaxAdjustmentProfile = (name == "Neon Glow") ? .neonRaw : .standard
let profile: SyntaxAdjustmentProfile = {
let vividNeonThemes: Set<String> = [
"Neon Glow",
"Neon Voltage",
"Laserwave",
"Cyber Lime",
"Plasma Storm",
"Inferno Neon",
"Ultraviolet Flux"
]
return vividNeonThemes.contains(name) ? .neonRaw : .standard
}()
let keyword = adjustedSyntaxColor(
palette.keyword,
colorScheme: colorScheme,

View file

@ -262,3 +262,8 @@
"Refresh Folder Tree" = "Ordnerbaum aktualisieren";
"Reset to Default" = "Auf Standard zurücksetzen";
"Use Default Template" = "Standardvorlage verwenden";
"Close All Tabs" = "Alle Tabs schließen";
"Are you sure you want to close all tabs?" = "Möchtest du wirklich alle Tabs schließen?";
"Cant Open File" = "Datei kann nicht geöffnet werden";
"The file \"%@\" is not supported and cant be opened." = "Die Datei \"%@\" wird nicht unterstützt und kann nicht geöffnet werden.";
"Show Supported Files Only" = "Nur unterstützte Dateien anzeigen";

View file

@ -163,3 +163,8 @@
"The application does not automatically transmit full project folders, unrelated files, entire file system contents, contact data, location data, or device-specific identifiers." = "The application does not automatically transmit full project folders, unrelated files, entire file system contents, contact data, location data, or device-specific identifiers.";
"Authentication credentials (API keys) for external AI providers are stored securely in the system keychain and are transmitted only to the user-selected provider for the purpose of completing the AI request." = "Authentication credentials (API keys) for external AI providers are stored securely in the system keychain and are transmitted only to the user-selected provider for the purpose of completing the AI request.";
"All external communication is performed over encrypted HTTPS connections. If AI completion is disabled, the application performs no external AI-related network requests." = "All external communication is performed over encrypted HTTPS connections. If AI completion is disabled, the application performs no external AI-related network requests.";
"Close All Tabs" = "Close All Tabs";
"Are you sure you want to close all tabs?" = "Are you sure you want to close all tabs?";
"Cant Open File" = "Cant Open File";
"The file \"%@\" is not supported and cant be opened." = "The file \"%@\" is not supported and cant be opened.";
"Show Supported Files Only" = "Show Supported Files Only";

View file

@ -36,7 +36,7 @@
> Status: **active release**
> Latest release: **v0.5.0**
> Latest release: **v0.5.1**
> Platform target: **macOS 26 (Tahoe)** compatible with **macOS Sequoia**
> Apple Silicon: tested / Intel: not tested
> Last updated (README): **2026-03-08** for release line **v0.5.0**
@ -125,7 +125,7 @@ Prebuilt binaries are available on [GitHub Releases](https://github.com/h3pdesig
Best for direct notarized builds and fastest access to new stable versions.
- Download: [GitHub Releases](https://github.com/h3pdesign/Neon-Vision-Editor/releases)
- Latest release: **v0.5.0**
- Latest release: **v0.5.1**
- Channel: **Stable**
- Architecture: Apple Silicon (Intel not tested)
@ -398,6 +398,14 @@ All shortcuts use `Cmd` (`⌘`). iPad/iOS require a hardware keyboard.
## Changelog
### v0.5.1 (summary)
- Added bulk `Close All Tabs` actions to toolbar surfaces (macOS, iOS, iPadOS), including a confirmation step before closing.
- Added project-structure quick actions to expand all folders or collapse all folders in one step.
- Added six vivid neon syntax themes with distinct color profiles: `Neon Voltage`, `Laserwave`, `Cyber Lime`, `Plasma Storm`, `Inferno Neon`, and `Ultraviolet Flux`.
- Added a lock-safe cross-platform build matrix helper script (`scripts/ci/build_platform_matrix.sh`) to run macOS + iOS Simulator + iPad Simulator builds sequentially.
- Added iPhone Markdown preview as a bottom sheet with toolbar toggle and resizable detents for Apple-guideline-compliant height control.
### v0.5.0 (summary)
- Added updater staging hardening with retry/fallback behavior and staged-bundle integrity checks.
@ -414,14 +422,6 @@ All shortcuts use `Cmd` (`⌘`). iPad/iOS require a hardware keyboard.
- iOS/iPad settings cards were visually simplified by removing accent stripe lines across tabs.
- Wrapped-line numbering on iOS/iPad now uses sticky logical line numbers instead of repeating on every visual wrap row.
### v0.4.33 (summary)
- 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.
Full release history: [`CHANGELOG.md`](CHANGELOG.md)
## Known Limitations
@ -441,12 +441,12 @@ Full release history: [`CHANGELOG.md`](CHANGELOG.md)
## Release Integrity
- Tag: `v0.5.0`
- Tag: `v0.5.1`
- Tagged commit: `1c31306`
- Verify local tag target:
```bash
git rev-parse --verify v0.5.0
git rev-parse --verify v0.5.1
```
- Verify downloaded artifact checksum locally:
@ -485,6 +485,12 @@ cd Neon-Vision-Editor
xcodebuild -project "Neon Vision Editor.xcodeproj" -scheme "Neon Vision Editor" -destination 'platform=macOS,name=My Mac' build
```
Lock-safe cross-platform verification (sequential macOS + iOS Simulator + iPad Simulator):
```bash
scripts/ci/build_platform_matrix.sh
```
## Support & Feedback
- Questions and ideas: [GitHub Discussions](https://github.com/h3pdesign/Neon-Vision-Editor/discussions)

View file

@ -0,0 +1,132 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
PROJECT="${PROJECT:-Neon Vision Editor.xcodeproj}"
SCHEME="${SCHEME:-Neon Vision Editor}"
CONFIGURATION="${CONFIGURATION:-Debug}"
CODE_SIGNING_ALLOWED="${CODE_SIGNING_ALLOWED:-NO}"
LOCK_DIR="${LOCK_DIR:-/tmp/nve_xcodebuild.lock}"
DERIVED_DATA_ROOT="${DERIVED_DATA_ROOT:-$ROOT/.DerivedDataMatrix}"
RETRIES="${RETRIES:-2}"
usage() {
cat <<'EOF'
Usage: scripts/ci/build_platform_matrix.sh [--keep-derived-data]
Runs build verification sequentially for:
1) macOS
2) iOS Simulator
3) iPad Simulator target family
Environment overrides:
PROJECT, SCHEME, CONFIGURATION, CODE_SIGNING_ALLOWED
LOCK_DIR, DERIVED_DATA_ROOT, RETRIES
EOF
}
KEEP_DERIVED_DATA=0
for arg in "$@"; do
case "$arg" in
--keep-derived-data) KEEP_DERIVED_DATA=1 ;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $arg" >&2
usage >&2
exit 1
;;
esac
done
wait_for_lock() {
local attempts=0
while ! mkdir "$LOCK_DIR" 2>/dev/null; do
attempts=$((attempts + 1))
if [[ "$attempts" -ge 120 ]]; then
echo "Timed out waiting for build lock: $LOCK_DIR" >&2
exit 1
fi
sleep 1
done
}
release_lock() {
rmdir "$LOCK_DIR" 2>/dev/null || true
}
cleanup() {
release_lock
if [[ "$KEEP_DERIVED_DATA" -eq 0 ]]; then
rm -rf "$DERIVED_DATA_ROOT"
fi
}
trap cleanup EXIT
is_lock_error() {
local log_file="$1"
rg -q "database is locked|build system has crashed|unable to attach DB|unexpected service error" "$log_file"
}
run_build() {
local name="$1"
shift
local platform_slug
platform_slug="$(echo "$name" | tr '[:upper:] ' '[:lower:]_')"
local derived_data_path="${DERIVED_DATA_ROOT}/${platform_slug}"
local log_file="/tmp/nve_build_${platform_slug}.log"
local attempt=0
rm -rf "$derived_data_path"
while :; do
attempt=$((attempt + 1))
echo "[$name] Attempt ${attempt}/${RETRIES}"
if xcodebuild \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration "$CONFIGURATION" \
-derivedDataPath "$derived_data_path" \
CODE_SIGNING_ALLOWED="$CODE_SIGNING_ALLOWED" \
"$@" >"$log_file" 2>&1; then
echo "[$name] BUILD SUCCEEDED"
return 0
fi
if [[ "$attempt" -lt "$RETRIES" ]] && is_lock_error "$log_file"; then
echo "[$name] Detected lock/build-system transient. Retrying..."
sleep 2
rm -rf "$derived_data_path"
continue
fi
echo "[$name] BUILD FAILED (see $log_file)" >&2
tail -n 40 "$log_file" >&2 || true
return 1
done
}
wait_for_lock
run_build "macOS" \
-destination "generic/platform=macOS" \
build
run_build "iOS Simulator" \
-sdk iphonesimulator \
-destination "generic/platform=iOS Simulator" \
build
run_build "iPad Simulator" \
-sdk iphonesimulator \
-destination "generic/platform=iOS Simulator" \
TARGETED_DEVICE_FAMILY=2 \
build
echo "Build matrix completed successfully."