diff --git a/CHANGELOG.md b/CHANGELOG.md index 86bb4b9..6822c18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index acc0312..c7b14d8 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 = 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; diff --git a/Neon Vision Editor/Core/LanguageDetector.swift b/Neon Vision Editor/Core/LanguageDetector.swift index f8dba80..8cf1341 100644 --- a/Neon Vision Editor/Core/LanguageDetector.swift +++ b/Neon Vision Editor/Core/LanguageDetector.swift @@ -28,6 +28,7 @@ public struct LanguageDetector { "yaml": "yaml", "yml": "yaml", "xml": "xml", + "svg": "xml", "plist": "xml", "sql": "sql", "log": "log", diff --git a/Neon Vision Editor/Data/EditorViewModel.swift b/Neon Vision Editor/Data/EditorViewModel.swift index d66cf0e..7fae047 100644 --- a/Neon Vision Editor/Data/EditorViewModel.swift +++ b/Neon Vision Editor/Data/EditorViewModel.swift @@ -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 = [ + ".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 = [ + "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("Can’t Open File", comment: "Unsupported file alert title") + let format = NSLocalizedString("The file \"%@\" is not supported and can’t 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) diff --git a/Neon Vision Editor/UI/ContentView+Actions.swift b/Neon Vision Editor/UI/ContentView+Actions.swift index 5d0ea79..44c902a 100644 --- a/Neon Vision Editor/UI/ContentView+Actions.swift +++ b/Neon Vision Editor/UI/ContentView+Actions.swift @@ -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 diff --git a/Neon Vision Editor/UI/ContentView+Toolbar.swift b/Neon Vision Editor/UI/ContentView+Toolbar.swift index 355291d..5b15b29 100644 --- a/Neon Vision Editor/UI/ContentView+Toolbar.swift +++ b/Neon Vision Editor/UI/ContentView+Toolbar.swift @@ -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() }) { diff --git a/Neon Vision Editor/UI/ContentView.swift b/Neon Vision Editor/UI/ContentView.swift index 08bd2d9..367a522 100644 --- a/Neon Vision Editor/UI/ContentView.swift +++ b/Neon Vision Editor/UI/ContentView.swift @@ -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 { + Binding( + get: { shouldPresentMarkdownPreviewSheetOnIPhone }, + set: { isPresented in + if !isPresented { + showMarkdownPreviewPane = false + } + } + ) + } +#endif + private var settingsSheetDetents: Set { #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("Can’t Open File", isPresented: contentView.$showUnsupportedFileAlert) { + Button("OK", role: .cancel) { } + } message: { + Text(String( + format: NSLocalizedString( + "The file \"%@\" is not supported and can’t 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 } } diff --git a/Neon Vision Editor/UI/PanelsAndHelpers.swift b/Neon Vision Editor/UI/PanelsAndHelpers.swift index 5eb7133..1311cb3 100644 --- a/Neon Vision Editor/UI/PanelsAndHelpers.swift +++ b/Neon Vision Editor/UI/PanelsAndHelpers.swift @@ -337,12 +337,12 @@ struct WelcomeTourView: View { private let pages: [TourPage] = [ TourPage( title: "What’s 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)], diff --git a/Neon Vision Editor/UI/SidebarViews.swift b/Neon Vision Editor/UI/SidebarViews.swift index 56fd9b9..81fa98c 100644 --- a/Neon Vision Editor/UI/SidebarViews.swift +++ b/Neon Vision Editor/UI/SidebarViews.swift @@ -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 = [] @@ -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 { + var result: Set = [] + 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( diff --git a/Neon Vision Editor/UI/ThemeSettings.swift b/Neon Vision Editor/UI/ThemeSettings.swift index 7d9439c..3d1545f 100644 --- a/Neon Vision Editor/UI/ThemeSettings.swift +++ b/Neon Vision Editor/UI/ThemeSettings.swift @@ -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 = [ + "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, diff --git a/Neon Vision Editor/de.lproj/Localizable.strings b/Neon Vision Editor/de.lproj/Localizable.strings index 4424261..603da60 100644 --- a/Neon Vision Editor/de.lproj/Localizable.strings +++ b/Neon Vision Editor/de.lproj/Localizable.strings @@ -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?"; +"Can’t Open File" = "Datei kann nicht geöffnet werden"; +"The file \"%@\" is not supported and can’t be opened." = "Die Datei \"%@\" wird nicht unterstützt und kann nicht geöffnet werden."; +"Show Supported Files Only" = "Nur unterstützte Dateien anzeigen"; diff --git a/Neon Vision Editor/en.lproj/Localizable.strings b/Neon Vision Editor/en.lproj/Localizable.strings index 38b789a..65f350f 100644 --- a/Neon Vision Editor/en.lproj/Localizable.strings +++ b/Neon Vision Editor/en.lproj/Localizable.strings @@ -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?"; +"Can’t Open File" = "Can’t Open File"; +"The file \"%@\" is not supported and can’t be opened." = "The file \"%@\" is not supported and can’t be opened."; +"Show Supported Files Only" = "Show Supported Files Only"; diff --git a/README.md b/README.md index 452f516..96e8549 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/scripts/ci/build_platform_matrix.sh b/scripts/ci/build_platform_matrix.sh new file mode 100755 index 0000000..d5e50a6 --- /dev/null +++ b/scripts/ci/build_platform_matrix.sh @@ -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."