From 493be746daaf42f7b72776f6a4e825494765718e Mon Sep 17 00:00:00 2001 From: h3p Date: Thu, 16 Apr 2026 12:37:03 +0200 Subject: [PATCH] Release v0.6.1 updates and changelog --- CHANGELOG.md | 26 ++ Neon Vision Editor.xcodeproj/project.pbxproj | 1 + .../App/NeonVisionEditorApp.swift | 76 +++- Neon Vision Editor/Data/EditorViewModel.swift | 25 ++ .../UI/ContentView+Actions.swift | 365 ++++++++++++++++++ .../ContentView+MarkdownPreviewExport.swift | 6 +- .../UI/ContentView+Toolbar.swift | 178 ++++++++- Neon Vision Editor/UI/ContentView.swift | 118 +++++- Neon Vision Editor/UI/EditorTextView.swift | 120 +++--- Neon Vision Editor/UI/NeonSettingsView.swift | 73 +++- Neon Vision Editor/UI/SidebarViews.swift | 335 +++++++++++++--- .../de.lproj/Localizable.strings | 49 ++- .../en.lproj/Localizable.strings | 48 ++- .../zh-Hans.lproj/Localizable.strings | 118 ++++++ 14 files changed, 1370 insertions(+), 168 deletions(-) create mode 100644 Neon Vision Editor/zh-Hans.lproj/Localizable.strings diff --git a/CHANGELOG.md b/CHANGELOG.md index 591a1fc..b782dbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,32 @@ The format follows *Keep a Changelog*. Versions use semantic versioning with pre ### Migration - None. +## [v0.6.1] - 2026-04-16 + +### Why Upgrade +- The project sidebar is now more complete for day-to-day file management with better structure controls and direct item actions. +- Markdown Preview toolbar controls are cleaner and more discoverable with dedicated export/style actions plus localized labels. + +### Highlights +- Added project sidebar item actions for creating files/folders, plus rename, duplicate, and delete flows. +- Refined project sidebar visual hierarchy and interaction density for clearer navigation in large trees. +- Added a dedicated Markdown Preview style toolbar button and consolidated export options into toolbar menus that appear only when preview is active. +- Expanded localization coverage for new Markdown Preview toolbar strings (including Simplified Chinese additions). + +### Fixes +- Fixed missing localization coverage for newly introduced Markdown Preview toolbar labels/help text. +- Fixed Markdown Preview toolbar/menu availability so controls appear only in Markdown Preview mode. + +### Closed Issues (Milestone `0.6.1`) +- [#77](https://github.com/h3pdesign/Neon-Vision-Editor/issues/77) `[UI]: Refine project sidebar layout and visual hierarchy` +- [#78](https://github.com/h3pdesign/Neon-Vision-Editor/issues/78) `[Feature]: Add rename, delete, and duplicate actions for project items` + +### Breaking changes +- None. + +### Migration +- None. + ## [v0.6.0] - 2026-03-30 ### Why Upgrade diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index ab5e70d..2950286 100644 --- a/Neon Vision Editor.xcodeproj/project.pbxproj +++ b/Neon Vision Editor.xcodeproj/project.pbxproj @@ -149,6 +149,7 @@ en, Base, de, + "zh-Hans", ); mainGroup = 98EAE62A2E5F15E80050E579; minimizedProjectReferenceProxies = 1; diff --git a/Neon Vision Editor/App/NeonVisionEditorApp.swift b/Neon Vision Editor/App/NeonVisionEditorApp.swift index d79c84c..be44f77 100644 --- a/Neon Vision Editor/App/NeonVisionEditorApp.swift +++ b/Neon Vision Editor/App/NeonVisionEditorApp.swift @@ -1,4 +1,5 @@ import SwiftUI +import ObjectiveC.runtime #if canImport(FoundationModels) import FoundationModels #endif @@ -9,11 +10,50 @@ import AppKit import UIKit #endif -#if os(macOS) - - /// MARK: - Types +private var runtimeLanguageBundleAssociationKey: UInt8 = 0 + +private final class RuntimeLanguageBundle: Bundle, @unchecked Sendable { + override func localizedString(forKey key: String, value: String?, table tableName: String?) -> String { + if let languageBundle = objc_getAssociatedObject(self, &runtimeLanguageBundleAssociationKey) as? Bundle { + return languageBundle.localizedString(forKey: key, value: value, table: tableName) + } + return super.localizedString(forKey: key, value: value, table: tableName) + } +} + +private enum RuntimeLanguageOverride { + private static var didInstallBundleOverride = false + + static func apply(languageCode: String) { + installBundleOverrideIfNeeded() + let bundle = languageBundle(for: languageCode) + objc_setAssociatedObject( + Bundle.main, + &runtimeLanguageBundleAssociationKey, + bundle, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + + private static func installBundleOverrideIfNeeded() { + guard !didInstallBundleOverride else { return } + object_setClass(Bundle.main, RuntimeLanguageBundle.self) + didInstallBundleOverride = true + } + + private static func languageBundle(for languageCode: String) -> Bundle? { + guard languageCode != "system" else { return nil } + if let exact = Bundle.main.path(forResource: languageCode, ofType: "lproj").flatMap(Bundle.init(path:)) { + return exact + } + let fallbackCode = languageCode.split(separator: "-").first.map(String.init) ?? languageCode + return Bundle.main.path(forResource: fallbackCode, ofType: "lproj").flatMap(Bundle.init(path:)) + } +} + +#if os(macOS) final class AppDelegate: NSObject, NSApplicationDelegate { weak var viewModel: EditorViewModel? { didSet { @@ -90,6 +130,7 @@ struct NeonVisionEditorApp: App { @StateObject private var supportPurchaseManager = SupportPurchaseManager() @StateObject private var appUpdateManager = AppUpdateManager() @AppStorage("SettingsAppearance") private var appearance: String = "system" + @AppStorage("SettingsAppLanguageCode") private var appLanguageCode: String = "system" @Environment(\.scenePhase) private var scenePhase private let mainStartupBehavior: ContentView.StartupBehavior private let startupSafeModeMessage: String? @@ -108,6 +149,16 @@ struct NeonVisionEditorApp: App { ReleaseRuntimePolicy.preferredColorScheme(for: appearance) } + private var preferredLocale: Locale { + appLanguageCode == "system" + ? .autoupdatingCurrent + : Locale(identifier: appLanguageCode) + } + + private func applyRuntimeLanguageOverride() { + RuntimeLanguageOverride.apply(languageCode: appLanguageCode) + } + private func completeLaunchReliabilityTrackingIfNeeded() { guard !didMarkLaunchCompleted else { return } didMarkLaunchCompleted = true @@ -217,6 +268,7 @@ struct NeonVisionEditorApp: App { "SettingsCompletionFromSyntax": false, "SettingsReopenLastSession": true, "SettingsOpenWithBlankDocument": false, + "SettingsAppLanguageCode": "system", "SettingsDefaultNewFileLanguage": "plain", "SettingsConfirmCloseDirtyTab": true, "SettingsConfirmClearEditor": true, @@ -248,6 +300,9 @@ struct NeonVisionEditorApp: App { self.startupSafeModeMessage = safeModeDecision.message RuntimeReliabilityMonitor.shared.startMainThreadWatchdog() EditorPerformanceMonitor.shared.markLaunchConfigured() + RuntimeLanguageOverride.apply( + languageCode: defaults.string(forKey: "SettingsAppLanguageCode") ?? "system" + ) } #if os(macOS) @@ -289,8 +344,11 @@ struct NeonVisionEditorApp: App { .onAppear { applyGlobalAppearanceOverride() } .onAppear { applyMacWindowTabbingPolicy() } .onChange(of: appearance) { _, _ in applyGlobalAppearanceOverride() } + .onAppear { applyRuntimeLanguageOverride() } + .onChange(of: appLanguageCode) { _, _ in applyRuntimeLanguageOverride() } .environment(\.showGrokError, $showGrokError) .environment(\.grokErrorMessage, $grokErrorMessage) + .environment(\.locale, preferredLocale) .tint(.blue) .preferredColorScheme(preferredAppearance) .onChange(of: scenePhase) { _, newPhase in @@ -347,6 +405,9 @@ struct NeonVisionEditorApp: App { .onAppear { applyGlobalAppearanceOverride() } .onAppear { applyMacWindowTabbingPolicy() } .onChange(of: appearance) { _, _ in applyGlobalAppearanceOverride() } + .onAppear { applyRuntimeLanguageOverride() } + .onChange(of: appLanguageCode) { _, _ in applyRuntimeLanguageOverride() } + .environment(\.locale, preferredLocale) .tint(.blue) .preferredColorScheme(preferredAppearance) } @@ -364,6 +425,9 @@ struct NeonVisionEditorApp: App { .onAppear { applyGlobalAppearanceOverride() } .onAppear { applyMacWindowTabbingPolicy() } .onChange(of: appearance) { _, _ in applyGlobalAppearanceOverride() } + .onAppear { applyRuntimeLanguageOverride() } + .onChange(of: appLanguageCode) { _, _ in applyRuntimeLanguageOverride() } + .environment(\.locale, preferredLocale) .tint(.blue) .preferredColorScheme(preferredAppearance) } @@ -371,6 +435,9 @@ struct NeonVisionEditorApp: App { Window("AI Activity Log", id: "ai-logs") { AIActivityLogView() .frame(minWidth: 720, minHeight: 420) + .onAppear { applyRuntimeLanguageOverride() } + .onChange(of: appLanguageCode) { _, _ in applyRuntimeLanguageOverride() } + .environment(\.locale, preferredLocale) .preferredColorScheme(preferredAppearance) .tint(.blue) } @@ -441,6 +508,9 @@ struct NeonVisionEditorApp: App { .environmentObject(appUpdateManager) .environment(\.showGrokError, $showGrokError) .environment(\.grokErrorMessage, $grokErrorMessage) + .environment(\.locale, preferredLocale) + .onAppear { applyRuntimeLanguageOverride() } + .onChange(of: appLanguageCode) { _, _ in applyRuntimeLanguageOverride() } .tint(.blue) .onAppear { applyIOSAppearanceOverride() } .onChange(of: scenePhase) { _, newPhase in diff --git a/Neon Vision Editor/Data/EditorViewModel.swift b/Neon Vision Editor/Data/EditorViewModel.swift index efe31c8..6eeef13 100644 --- a/Neon Vision Editor/Data/EditorViewModel.swift +++ b/Neon Vision Editor/Data/EditorViewModel.swift @@ -632,6 +632,7 @@ class EditorViewModel { private enum TabCommand: Sendable { case updateContent(tabID: UUID, mutation: TabContentMutation) case markSaved(tabID: UUID, fileURL: URL?, fingerprint: UInt64?, fileModificationDate: Date?) + case remapFileURL(tabID: UUID, fileURL: URL) case setLanguage(tabID: UUID, language: String, lock: Bool) case closeTab(tabID: UUID) case addNewTab(name: String, language: String) @@ -704,6 +705,25 @@ class EditorViewModel { recordTabStateMutation(rebuildIndexes: true) return outcome + case let .remapFileURL(tabID, fileURL): + guard let index = tabIndex(for: tabID) else { return TabCommandOutcome() } + let standardizedTarget = fileURL.standardizedFileURL + let currentPath = tabs[index].fileURL?.standardizedFileURL.path + if currentPath == standardizedTarget.path, tabs[index].name == standardizedTarget.lastPathComponent { + return TabCommandOutcome(index: index) + } + tabs[index].fileURL = standardizedTarget + tabs[index].name = standardizedTarget.lastPathComponent + if let mapped = LanguageDetector.shared.preferredLanguage(for: standardizedTarget) ?? + languageMap[standardizedTarget.pathExtension.lowercased()] { + tabs[index].language = mapped + tabs[index].languageLocked = true + } + let fileDate = (try? standardizedTarget.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? nil + tabs[index].updateLastKnownFileModificationDate(fileDate) + recordTabStateMutation(rebuildIndexes: true) + return TabCommandOutcome(index: index) + case let .setLanguage(tabID, language, lock): guard let index = tabIndex(for: tabID) else { return TabCommandOutcome() } if tabs[index].language == language, tabs[index].languageLocked == lock { @@ -1963,6 +1983,11 @@ class EditorViewModel { ) } + // Remaps a tab's file URL after an external move/rename while preserving dirty state. + func remapTabFileURL(tabID: UUID, to fileURL: URL) { + _ = applyTabCommand(.remapFileURL(tabID: tabID, fileURL: fileURL)) + } + // Returns whitespace-delimited word count for status display. func wordCount(for text: String) -> Int { text.split(whereSeparator: \.isWhitespace).count diff --git a/Neon Vision Editor/UI/ContentView+Actions.swift b/Neon Vision Editor/UI/ContentView+Actions.swift index cc428aa..58bf10f 100644 --- a/Neon Vision Editor/UI/ContentView+Actions.swift +++ b/Neon Vision Editor/UI/ContentView+Actions.swift @@ -976,6 +976,370 @@ extension ContentView { persistSessionIfReady() } + func startProjectItemCreation(kind: ProjectSidebarCreationKind, in preferredDirectory: URL?) { + guard let root = projectRootFolderURL else { return } + let directory = resolvedProjectCreationDirectory(preferredDirectory, root: root) + projectItemCreationKind = kind + projectItemCreationParentURL = directory + projectItemCreationNameDraft = suggestedProjectItemName(for: kind, in: directory) + showProjectItemCreationPrompt = true + } + + func cancelProjectItemCreation() { + showProjectItemCreationPrompt = false + projectItemCreationNameDraft = "" + projectItemCreationParentURL = nil + } + + func confirmProjectItemCreation() { + guard let root = projectRootFolderURL else { + cancelProjectItemCreation() + return + } + let targetDirectory = resolvedProjectCreationDirectory(projectItemCreationParentURL, root: root) + let trimmedName = projectItemCreationNameDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard validateProjectItemName(trimmedName) else { + presentProjectItemOperationError( + NSLocalizedString("Use a valid name without slashes.", comment: "Project item name validation error") + ) + return + } + + let targetURL = targetDirectory.appendingPathComponent(trimmedName, isDirectory: projectItemCreationKind == .folder) + if FileManager.default.fileExists(atPath: targetURL.path) { + presentProjectItemOperationError( + NSLocalizedString("An item with this name already exists.", comment: "Project item already exists error") + ) + return + } + + do { + switch projectItemCreationKind { + case .file: + let created = FileManager.default.createFile(atPath: targetURL.path, contents: Data(), attributes: nil) + if !created { + throw CocoaError(.fileWriteUnknown) + } + case .folder: + try FileManager.default.createDirectory(at: targetURL, withIntermediateDirectories: false, attributes: nil) + } + } catch { + presentProjectItemOperationError(error.localizedDescription) + return + } + + revealProjectItem(targetURL) + if projectItemCreationKind == .file, EditorViewModel.isSupportedEditorFileURL(targetURL) { + openProjectFile(url: targetURL) + } + cancelProjectItemCreation() + } + + func startProjectItemRename(_ itemURL: URL) { + guard let root = projectRootFolderURL, + let targetURL = resolvedProjectItemURL(itemURL, root: root) else { return } + projectItemRenameSourceURL = targetURL + projectItemRenameNameDraft = targetURL.lastPathComponent + showProjectItemRenamePrompt = true + } + + func cancelProjectItemRename() { + showProjectItemRenamePrompt = false + projectItemRenameSourceURL = nil + projectItemRenameNameDraft = "" + } + + func confirmProjectItemRename() { + guard let root = projectRootFolderURL, + let sourceURL = projectItemRenameSourceURL, + let resolvedSourceURL = resolvedProjectItemURL(sourceURL, root: root) else { + cancelProjectItemRename() + return + } + let trimmedName = projectItemRenameNameDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard validateProjectItemName(trimmedName) else { + presentProjectItemOperationError( + NSLocalizedString("Use a valid name without slashes.", comment: "Project item name validation error") + ) + return + } + + var isDirectory: ObjCBool = false + let sourcePath = resolvedSourceURL.path + guard FileManager.default.fileExists(atPath: sourcePath, isDirectory: &isDirectory) else { + presentProjectItemOperationError( + NSLocalizedString("The selected item no longer exists.", comment: "Project item missing error") + ) + cancelProjectItemRename() + return + } + + let destinationURL = resolvedSourceURL + .deletingLastPathComponent() + .appendingPathComponent(trimmedName, isDirectory: isDirectory.boolValue) + .standardizedFileURL + if destinationURL == resolvedSourceURL { + cancelProjectItemRename() + return + } + let destinationExists = FileManager.default.fileExists(atPath: destinationURL.path) + let isCaseOnlyRename = isCaseOnlyRename(from: resolvedSourceURL, to: destinationURL) + if destinationExists && !isCaseOnlyRename { + presentProjectItemOperationError( + NSLocalizedString("An item with this name already exists.", comment: "Project item already exists error") + ) + return + } + + do { + if destinationExists && isCaseOnlyRename { + // Case-only rename on a case-insensitive volume needs a temporary hop. + let hopURL = temporaryRenameHopURL(for: resolvedSourceURL, isDirectory: isDirectory.boolValue) + try FileManager.default.moveItem(at: resolvedSourceURL, to: hopURL) + do { + try FileManager.default.moveItem(at: hopURL, to: destinationURL) + } catch { + try? FileManager.default.moveItem(at: hopURL, to: resolvedSourceURL) + throw error + } + } else { + try FileManager.default.moveItem(at: resolvedSourceURL, to: destinationURL) + } + } catch { + presentProjectItemOperationError(error.localizedDescription) + return + } + + relinkOpenTabsIfNeeded(from: resolvedSourceURL, to: destinationURL, isDirectory: isDirectory.boolValue) + revealProjectItem(destinationURL) + cancelProjectItemRename() + } + + func duplicateProjectItem(_ itemURL: URL) { + guard let root = projectRootFolderURL, + let sourceURL = resolvedProjectItemURL(itemURL, root: root) else { return } + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory) else { + presentProjectItemOperationError( + NSLocalizedString("The selected item no longer exists.", comment: "Project item missing error") + ) + return + } + + let destinationURL = uniqueDuplicateURL(for: sourceURL, isDirectory: isDirectory.boolValue) + do { + try FileManager.default.copyItem(at: sourceURL, to: destinationURL) + } catch { + presentProjectItemOperationError(error.localizedDescription) + return + } + + revealProjectItem(destinationURL) + } + + func requestDeleteProjectItem(_ itemURL: URL) { + guard let root = projectRootFolderURL, + let targetURL = resolvedProjectItemURL(itemURL, root: root) else { return } + projectItemDeleteTargetURL = targetURL + projectItemDeleteTargetName = targetURL.lastPathComponent + showProjectItemDeleteConfirmation = true + } + + func cancelDeleteProjectItem() { + showProjectItemDeleteConfirmation = false + projectItemDeleteTargetURL = nil + projectItemDeleteTargetName = "" + } + + func confirmDeleteProjectItem() { + guard let root = projectRootFolderURL, + let targetURL = projectItemDeleteTargetURL, + let resolvedTargetURL = resolvedProjectItemURL(targetURL, root: root) else { + cancelDeleteProjectItem() + return + } + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: resolvedTargetURL.path, isDirectory: &isDirectory) else { + presentProjectItemOperationError( + NSLocalizedString("The selected item no longer exists.", comment: "Project item missing error") + ) + cancelDeleteProjectItem() + return + } + + do { + try FileManager.default.removeItem(at: resolvedTargetURL) + } catch { + presentProjectItemOperationError(error.localizedDescription) + return + } + + closeCleanOpenTabsIfDeletedItemWasOpen(resolvedTargetURL, isDirectory: isDirectory.boolValue) + revealProjectItem(resolvedTargetURL.deletingLastPathComponent()) + cancelDeleteProjectItem() + } + + private func presentProjectItemOperationError(_ message: String) { + projectItemOperationErrorMessage = message + showProjectItemOperationErrorAlert = true + } + + private func validateProjectItemName(_ name: String) -> Bool { + guard !name.isEmpty else { return false } + if name == "." || name == ".." { return false } + let invalidCharacters = CharacterSet(charactersIn: "/:") + return name.rangeOfCharacter(from: invalidCharacters) == nil + } + + private func resolvedProjectCreationDirectory(_ candidate: URL?, root: URL) -> URL { + let standardizedRoot = root.standardizedFileURL + guard let candidate else { return standardizedRoot } + + let standardizedCandidate = candidate.standardizedFileURL + let standardizedPath = standardizedCandidate.path + let rootPath = standardizedRoot.path + let isInsideRoot = standardizedPath == rootPath || standardizedPath.hasPrefix(rootPath + "/") + guard isInsideRoot else { return standardizedRoot } + + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: standardizedPath, isDirectory: &isDirectory), isDirectory.boolValue { + return standardizedCandidate + } + + let parent = standardizedCandidate.deletingLastPathComponent().standardizedFileURL + let parentPath = parent.path + if parentPath == rootPath || parentPath.hasPrefix(rootPath + "/") { + return parent + } + return standardizedRoot + } + + private func resolvedProjectItemURL(_ candidate: URL, root: URL) -> URL? { + let standardizedRoot = root.standardizedFileURL + let standardizedCandidate = candidate.standardizedFileURL + let candidatePath = standardizedCandidate.path + let rootPath = standardizedRoot.path + let isInsideRoot = candidatePath == rootPath || candidatePath.hasPrefix(rootPath + "/") + guard isInsideRoot else { return nil } + guard FileManager.default.fileExists(atPath: candidatePath) else { return nil } + return standardizedCandidate + } + + private func uniqueDuplicateURL(for sourceURL: URL, isDirectory: Bool) -> URL { + let fm = FileManager.default + let parent = sourceURL.deletingLastPathComponent() + let ext = sourceURL.pathExtension + let stem = ext.isEmpty ? sourceURL.lastPathComponent : sourceURL.deletingPathExtension().lastPathComponent + + let firstName: String + if ext.isEmpty { + firstName = "\(stem) copy" + } else { + firstName = "\(stem) copy.\(ext)" + } + var candidateURL = parent.appendingPathComponent(firstName, isDirectory: isDirectory) + if !fm.fileExists(atPath: candidateURL.path) { + return candidateURL + } + + for index in 2...500 { + let candidateName: String + if ext.isEmpty { + candidateName = "\(stem) copy \(index)" + } else { + candidateName = "\(stem) copy \(index).\(ext)" + } + candidateURL = parent.appendingPathComponent(candidateName, isDirectory: isDirectory) + if !fm.fileExists(atPath: candidateURL.path) { + return candidateURL + } + } + return parent.appendingPathComponent(UUID().uuidString, isDirectory: isDirectory) + } + + private func isCaseOnlyRename(from sourceURL: URL, to destinationURL: URL) -> Bool { + let sourcePath = sourceURL.standardizedFileURL.path + let destinationPath = destinationURL.standardizedFileURL.path + guard sourcePath != destinationPath else { return false } + return sourcePath.compare(destinationPath, options: [.caseInsensitive]) == .orderedSame + } + + private func temporaryRenameHopURL(for sourceURL: URL, isDirectory: Bool) -> URL { + let parent = sourceURL.deletingLastPathComponent() + return parent.appendingPathComponent(".nve-rename-\(UUID().uuidString)", isDirectory: isDirectory) + } + + private func relinkOpenTabsIfNeeded(from sourceURL: URL, to destinationURL: URL, isDirectory: Bool) { + let sourcePath = sourceURL.standardizedFileURL.path + let destinationPath = destinationURL.standardizedFileURL.path + for tab in viewModel.tabs { + guard let tabURL = tab.fileURL?.standardizedFileURL else { continue } + let tabPath = tabURL.path + if !isDirectory, tabPath == sourcePath { + viewModel.remapTabFileURL(tabID: tab.id, to: destinationURL) + continue + } + if isDirectory, (tabPath == sourcePath || tabPath.hasPrefix(sourcePath + "/")) { + let suffix = String(tabPath.dropFirst(sourcePath.count)) + let remappedURL = URL(fileURLWithPath: destinationPath + suffix).standardizedFileURL + viewModel.remapTabFileURL(tabID: tab.id, to: remappedURL) + } + } + } + + private func closeCleanOpenTabsIfDeletedItemWasOpen(_ deletedURL: URL, isDirectory: Bool) { + let deletedPath = deletedURL.standardizedFileURL.path + let tabsToClose = viewModel.tabs.compactMap { tab -> UUID? in + guard !tab.isDirty, let tabURL = tab.fileURL?.standardizedFileURL else { return nil } + if isDirectory { + let tabPath = tabURL.path + if tabPath == deletedPath || tabPath.hasPrefix(deletedPath + "/") { + return tab.id + } + return nil + } + return tabURL.path == deletedPath ? tab.id : nil + } + for tabID in tabsToClose { + viewModel.closeTab(tabID: tabID) + } + } + + private func revealProjectItem(_ revealURL: URL) { + projectTreeRevealURL = revealURL.standardizedFileURL + refreshProjectBrowserState() + let revealedURL = revealURL.standardizedFileURL + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + if self.projectTreeRevealURL?.standardizedFileURL == revealedURL { + self.projectTreeRevealURL = nil + } + } + } + + private func suggestedProjectItemName(for kind: ProjectSidebarCreationKind, in directory: URL) -> String { + let baseName: String = kind == .file ? "Untitled.txt" : "New Folder" + let fm = FileManager.default + if !fm.fileExists(atPath: directory.appendingPathComponent(baseName, isDirectory: kind == .folder).path) { + return baseName + } + + for index in 2...500 { + let candidate: String + if kind == .file { + candidate = "Untitled \(index).txt" + } else { + candidate = "New Folder \(index)" + } + let candidateURL = directory.appendingPathComponent(candidate, isDirectory: kind == .folder) + if !fm.fileExists(atPath: candidateURL.path) { + return candidate + } + } + return baseName + } + 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 [] } @@ -1001,6 +1365,7 @@ extension ContentView { #endif projectRootFolderURL = folderURL projectTreeNodes = [] + projectTreeRevealURL = nil quickSwitcherProjectFileURLs = [] projectFileIndexSnapshot = .empty isProjectFileIndexing = false diff --git a/Neon Vision Editor/UI/ContentView+MarkdownPreviewExport.swift b/Neon Vision Editor/UI/ContentView+MarkdownPreviewExport.swift index 11120ed..90a9d0b 100644 --- a/Neon Vision Editor/UI/ContentView+MarkdownPreviewExport.swift +++ b/Neon Vision Editor/UI/ContentView+MarkdownPreviewExport.swift @@ -267,17 +267,17 @@ extension ContentView { return """ \(previewLayoutCSS) html { - -webkit-text-size-adjust: \(isPad ? "144%" : "118%"); + -webkit-text-size-adjust: \(isPad ? "126%" : "108%"); } body { - font-size: \(isPad ? "1.24em" : "1.1em"); + font-size: \(isPad ? "1.08em" : "0.98em"); } """ #else return """ \(previewLayoutCSS) body { - font-size: 1.08em; + font-size: 0.96em; } """ #endif diff --git a/Neon Vision Editor/UI/ContentView+Toolbar.swift b/Neon Vision Editor/UI/ContentView+Toolbar.swift index d8cb956..429d41c 100644 --- a/Neon Vision Editor/UI/ContentView+Toolbar.swift +++ b/Neon Vision Editor/UI/ContentView+Toolbar.swift @@ -55,6 +55,63 @@ extension ContentView { return NeonUIStyle.accentBlue } } + + @ViewBuilder + private var markdownPreviewExportToolbarMenuContent: some View { + Button(action: { exportMarkdownPreviewPDF() }) { + Label("Export PDF", systemImage: "square.and.arrow.down") + } + + Divider() + + Menu { + Button(action: { markdownPDFExportModeRaw = MarkdownPDFExportMode.paginatedFit.rawValue }) { + if markdownPDFExportModeRaw == MarkdownPDFExportMode.paginatedFit.rawValue { + Label("Paginated Fit", systemImage: "checkmark") + } else { + Text("Paginated Fit") + } + } + Button(action: { markdownPDFExportModeRaw = MarkdownPDFExportMode.onePageFit.rawValue }) { + if markdownPDFExportModeRaw == MarkdownPDFExportMode.onePageFit.rawValue { + Label("One Page Fit", systemImage: "checkmark") + } else { + Text("One Page Fit") + } + } + } label: { + Label("PDF Mode", systemImage: "doc.text") + } + + Menu { + Button("Default") { markdownPreviewTemplateRaw = "default" } + Button("Docs") { markdownPreviewTemplateRaw = "docs" } + Button("Article") { markdownPreviewTemplateRaw = "article" } + Button("Compact") { markdownPreviewTemplateRaw = "compact" } + Divider() + Button("GitHub Docs") { markdownPreviewTemplateRaw = "github-docs" } + Button("Academic Paper") { markdownPreviewTemplateRaw = "academic-paper" } + Button("Terminal Notes") { markdownPreviewTemplateRaw = "terminal-notes" } + Button("Magazine") { markdownPreviewTemplateRaw = "magazine" } + Button("Minimal Reader") { markdownPreviewTemplateRaw = "minimal-reader" } + Button("Presentation") { markdownPreviewTemplateRaw = "presentation" } + Button("Night Contrast") { markdownPreviewTemplateRaw = "night-contrast" } + Button("Warm Sepia") { markdownPreviewTemplateRaw = "warm-sepia" } + Button("Dense Compact") { markdownPreviewTemplateRaw = "dense-compact" } + Button("Developer Spec") { markdownPreviewTemplateRaw = "developer-spec" } + } label: { + Label(NSLocalizedString("Preview Style", comment: "Markdown preview style menu label"), systemImage: "paintbrush") + } + + Divider() + + Button(action: { copyMarkdownPreviewHTML() }) { + Label("Copy HTML", systemImage: "doc.on.doc") + } + Button(action: { copyMarkdownPreviewMarkdown() }) { + Label("Copy Markdown", systemImage: "doc.on.clipboard") + } + } #endif #if os(iOS) @@ -497,6 +554,103 @@ extension ContentView { .accessibilityLabel("Markdown Preview") } + @ViewBuilder + private var markdownPreviewExportControl: some View { + if showMarkdownPreviewPane && currentLanguage == "markdown" { + Menu { + markdownPreviewExportToolbarMenuContent + } label: { + Image(systemName: "square.and.arrow.down") + } + .help(NSLocalizedString("Markdown Preview Export Options", comment: "Toolbar help for markdown preview export options")) + .accessibilityLabel(NSLocalizedString("Export Markdown preview as PDF", comment: "Accessibility label for markdown preview export button")) + } + } + + @ViewBuilder + private var markdownPreviewStyleControl: some View { + if showMarkdownPreviewPane && currentLanguage == "markdown" { + Menu { + Button("Default") { markdownPreviewTemplateRaw = "default" } + Button("Docs") { markdownPreviewTemplateRaw = "docs" } + Button("Article") { markdownPreviewTemplateRaw = "article" } + Button("Compact") { markdownPreviewTemplateRaw = "compact" } + Divider() + Button("GitHub Docs") { markdownPreviewTemplateRaw = "github-docs" } + Button("Academic Paper") { markdownPreviewTemplateRaw = "academic-paper" } + Button("Terminal Notes") { markdownPreviewTemplateRaw = "terminal-notes" } + Button("Magazine") { markdownPreviewTemplateRaw = "magazine" } + Button("Minimal Reader") { markdownPreviewTemplateRaw = "minimal-reader" } + Button("Presentation") { markdownPreviewTemplateRaw = "presentation" } + Button("Night Contrast") { markdownPreviewTemplateRaw = "night-contrast" } + Button("Warm Sepia") { markdownPreviewTemplateRaw = "warm-sepia" } + Button("Dense Compact") { markdownPreviewTemplateRaw = "dense-compact" } + Button("Developer Spec") { markdownPreviewTemplateRaw = "developer-spec" } + } label: { + Image(systemName: "paintbrush") + } + .help(NSLocalizedString("Markdown Preview Template", comment: "Toolbar help for markdown preview style menu")) + .accessibilityLabel(NSLocalizedString("Markdown Preview Template", comment: "Accessibility label for markdown preview style menu")) + } + } + + @ViewBuilder + private var markdownPreviewExportToolbarMenuContent: some View { + Button(action: { exportMarkdownPreviewPDF() }) { + Label("Export PDF", systemImage: "square.and.arrow.down") + } + + Divider() + + Menu { + Button(action: { markdownPDFExportModeRaw = MarkdownPDFExportMode.paginatedFit.rawValue }) { + if markdownPDFExportModeRaw == MarkdownPDFExportMode.paginatedFit.rawValue { + Label("Paginated Fit", systemImage: "checkmark") + } else { + Text("Paginated Fit") + } + } + Button(action: { markdownPDFExportModeRaw = MarkdownPDFExportMode.onePageFit.rawValue }) { + if markdownPDFExportModeRaw == MarkdownPDFExportMode.onePageFit.rawValue { + Label("One Page Fit", systemImage: "checkmark") + } else { + Text("One Page Fit") + } + } + } label: { + Label("PDF Mode", systemImage: "doc.text") + } + + Menu { + Button("Default") { markdownPreviewTemplateRaw = "default" } + Button("Docs") { markdownPreviewTemplateRaw = "docs" } + Button("Article") { markdownPreviewTemplateRaw = "article" } + Button("Compact") { markdownPreviewTemplateRaw = "compact" } + Divider() + Button("GitHub Docs") { markdownPreviewTemplateRaw = "github-docs" } + Button("Academic Paper") { markdownPreviewTemplateRaw = "academic-paper" } + Button("Terminal Notes") { markdownPreviewTemplateRaw = "terminal-notes" } + Button("Magazine") { markdownPreviewTemplateRaw = "magazine" } + Button("Minimal Reader") { markdownPreviewTemplateRaw = "minimal-reader" } + Button("Presentation") { markdownPreviewTemplateRaw = "presentation" } + Button("Night Contrast") { markdownPreviewTemplateRaw = "night-contrast" } + Button("Warm Sepia") { markdownPreviewTemplateRaw = "warm-sepia" } + Button("Dense Compact") { markdownPreviewTemplateRaw = "dense-compact" } + Button("Developer Spec") { markdownPreviewTemplateRaw = "developer-spec" } + } label: { + Label(NSLocalizedString("Preview Style", comment: "Markdown preview style menu label"), systemImage: "paintbrush") + } + + Divider() + + Button(action: { copyMarkdownPreviewHTML() }) { + Label("Copy HTML", systemImage: "doc.on.doc") + } + Button(action: { copyMarkdownPreviewMarkdown() }) { + Label("Copy Markdown", systemImage: "doc.on.clipboard") + } + } + @ViewBuilder private var keyboardAccessoryControl: some View { Button(action: { @@ -767,6 +921,14 @@ extension ContentView { } .disabled(currentLanguage != "markdown") + if showMarkdownPreviewPane && currentLanguage == "markdown" { + Menu { + markdownPreviewExportToolbarMenuContent + } label: { + Label("Export PDF", systemImage: "square.and.arrow.down") + } + } + Button(action: { requestCloseAllTabsFromToolbar() }) { Label("Close All Tabs", systemImage: "xmark.square") } @@ -862,6 +1024,8 @@ extension ContentView { private var iOSToolbarControls: some View { openFileControl undoControl + markdownPreviewExportControl + markdownPreviewStyleControl if iPhonePromotedActionsCount >= 2 { newTabControl } if iPhonePromotedActionsCount >= 3 { saveFileControl } if iPhonePromotedActionsCount >= 4 { findReplaceControl } @@ -903,6 +1067,8 @@ extension ContentView { @ViewBuilder private var iPadDistributedToolbarControls: some View { languagePickerControl + markdownPreviewExportControl + markdownPreviewStyleControl ForEach(iPadPromotedActions, id: \.self) { action in iPadToolbarActionControl(action) .frame(minWidth: 40, minHeight: 40) @@ -1140,6 +1306,14 @@ extension ContentView { .help("Toggle Markdown Preview") if showMarkdownPreviewPane && currentLanguage == "markdown" { + Menu { + markdownPreviewExportToolbarMenuContent + } label: { + Label("Export PDF", systemImage: "square.and.arrow.down") + .foregroundStyle(macToolbarSymbolColor) + } + .help(NSLocalizedString("Markdown Preview Export Options", comment: "Toolbar help for markdown preview export options")) + Menu { Button("Default") { markdownPreviewTemplateRaw = "default" } Button("Docs") { markdownPreviewTemplateRaw = "docs" } @@ -1157,10 +1331,10 @@ extension ContentView { Button("Dense Compact") { markdownPreviewTemplateRaw = "dense-compact" } Button("Developer Spec") { markdownPreviewTemplateRaw = "developer-spec" } } label: { - Label("Preview Style", systemImage: "textformat.size") + Label(NSLocalizedString("Preview Style", comment: "Markdown preview style menu label"), systemImage: "paintbrush") .foregroundStyle(macToolbarSymbolColor) } - .help("Markdown Preview Template") + .help(NSLocalizedString("Markdown Preview Template", comment: "Toolbar help for markdown preview style menu")) } } #endif diff --git a/Neon Vision Editor/UI/ContentView.swift b/Neon Vision Editor/UI/ContentView.swift index 18d3c87..0664052 100644 --- a/Neon Vision Editor/UI/ContentView.swift +++ b/Neon Vision Editor/UI/ContentView.swift @@ -128,6 +128,29 @@ struct ContentView: View { var id: String { rawValue } } + enum ProjectSidebarCreationKind: String { + case file + case folder + + var title: String { + switch self { + case .file: + return NSLocalizedString("New File", comment: "Project sidebar creation title for files") + case .folder: + return NSLocalizedString("New Folder", comment: "Project sidebar creation title for folders") + } + } + + var namePlaceholder: String { + switch self { + case .file: + return NSLocalizedString("File name", comment: "Project sidebar file name placeholder") + case .folder: + return NSLocalizedString("Folder name", comment: "Project sidebar folder name placeholder") + } + } + } + struct DelimitedTableSnapshot: Sendable { let header: [String] let rows: [[String]] @@ -277,7 +300,9 @@ struct ContentView: View { @State var projectRootFolderURL: URL? = nil @State var projectTreeNodes: [ProjectTreeNode] = [] @State var projectTreeRefreshGeneration: Int = 0 + @State var projectTreeRevealURL: URL? = nil @AppStorage("SettingsShowSupportedProjectFilesOnly") var showSupportedProjectFilesOnly: Bool = true + @AppStorage("SettingsShowInvisibleCharacters") var showInvisibleCharacters: Bool = false @State var projectOverrideIndentWidth: Int? = nil @State var projectOverrideLineWrapEnabled: Bool? = nil @State var showProjectFolderPicker: Bool = false @@ -296,6 +321,18 @@ struct ContentView: View { @State var showIOSFileExporter: Bool = false @State var showUnsupportedFileAlert: Bool = false @State var unsupportedFileName: String = "" + @State var showProjectItemCreationPrompt: Bool = false + @State var projectItemCreationNameDraft: String = "" + @State var projectItemCreationKind: ProjectSidebarCreationKind = .file + @State var projectItemCreationParentURL: URL? = nil + @State var showProjectItemRenamePrompt: Bool = false + @State var projectItemRenameNameDraft: String = "" + @State var projectItemRenameSourceURL: URL? = nil + @State var showProjectItemDeleteConfirmation: Bool = false + @State var projectItemDeleteTargetURL: URL? = nil + @State var projectItemDeleteTargetName: String = "" + @State var showProjectItemOperationErrorAlert: Bool = false + @State var projectItemOperationErrorMessage: String = "" @State var iosExportDocument: PlainTextDocument = PlainTextDocument(text: "") @State var iosExportFilename: String = "Untitled.txt" @State var iosExportTabID: UUID? = nil @@ -336,7 +373,7 @@ struct ContentView: View { @State var droppedFileLoadProgress: Double = 0 @State var droppedFileLoadLabel: String = "" @State var largeFileModeEnabled: Bool = false - @SceneStorage("ProjectSidebarWidth") private var projectSidebarWidth: Double = 260 + @SceneStorage("ProjectSidebarWidth") private var projectSidebarWidth: Double = 320 @State private var projectSidebarResizeStartWidth: CGFloat? = nil @State private var delimitedViewMode: DelimitedViewMode = .table @State private var delimitedTableSnapshot: DelimitedTableSnapshot? = nil @@ -416,8 +453,11 @@ struct ContentView: View { PerformancePreset(rawValue: performancePresetRaw) ?? .balanced } + private var minimumProjectSidebarWidth: CGFloat { 320 } + private var maximumProjectSidebarWidth: CGFloat { 520 } + private var clampedProjectSidebarWidth: CGFloat { - let clamped = min(max(projectSidebarWidth, 220), 520) + let clamped = min(max(projectSidebarWidth, Double(minimumProjectSidebarWidth)), Double(maximumProjectSidebarWidth)) return CGFloat(clamped) } @@ -2304,8 +2344,8 @@ struct ContentView: View { viewModel.showSidebar = false showProjectStructureSidebar = false #if os(iOS) - if UIDevice.current.userInterfaceIdiom == .pad && abs(projectSidebarWidth - 260) < 0.5 { - projectSidebarWidth = 292 + if UIDevice.current.userInterfaceIdiom == .pad && projectSidebarWidth < Double(minimumProjectSidebarWidth) { + projectSidebarWidth = Double(minimumProjectSidebarWidth) } #endif didRunInitialWindowLayoutSetup = true @@ -2597,7 +2637,13 @@ struct ContentView: View { onOpenFolder: { contentView.openProjectFolder() }, onToggleSupportedFilesOnly: { contentView.showSupportedProjectFilesOnly = $0 }, onOpenProjectFile: { contentView.openProjectFile(url: $0) }, - onRefreshTree: { contentView.refreshProjectBrowserState() } + onRefreshTree: { contentView.refreshProjectBrowserState() }, + onCreateProjectFile: { contentView.startProjectItemCreation(kind: .file, in: $0) }, + onCreateProjectFolder: { contentView.startProjectItemCreation(kind: .folder, in: $0) }, + onRenameProjectItem: { contentView.startProjectItemRename($0) }, + onDuplicateProjectItem: { contentView.duplicateProjectItem($0) }, + onDeleteProjectItem: { contentView.requestDeleteProjectItem($0) }, + revealURL: contentView.projectTreeRevealURL ) .navigationTitle(Text(NSLocalizedString("Project Structure", comment: ""))) .toolbar { @@ -2895,6 +2941,51 @@ struct ContentView: View { contentView.unsupportedFileName )) } + .alert(contentView.projectItemCreationKind.title, isPresented: contentView.$showProjectItemCreationPrompt) { + TextField( + contentView.projectItemCreationKind.namePlaceholder, + text: contentView.$projectItemCreationNameDraft + ) + Button("Create") { contentView.confirmProjectItemCreation() } + Button("Cancel", role: .cancel) { contentView.cancelProjectItemCreation() } + } message: { + Text(NSLocalizedString("Choose a name for the new item.", comment: "Project item creation prompt message")) + } + .alert(NSLocalizedString("Rename Item", comment: "Project item rename alert title"), isPresented: contentView.$showProjectItemRenamePrompt) { + TextField( + NSLocalizedString("Name", comment: "Project item rename name field placeholder"), + text: contentView.$projectItemRenameNameDraft + ) + Button("Rename") { contentView.confirmProjectItemRename() } + Button("Cancel", role: .cancel) { contentView.cancelProjectItemRename() } + } message: { + Text(NSLocalizedString("Enter a new name.", comment: "Project item rename prompt message")) + } + .confirmationDialog( + NSLocalizedString("Delete Item?", comment: "Project item delete confirmation title"), + isPresented: contentView.$showProjectItemDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { contentView.confirmDeleteProjectItem() } + Button("Cancel", role: .cancel) { contentView.cancelDeleteProjectItem() } + } message: { + if !contentView.projectItemDeleteTargetName.isEmpty { + Text( + String( + format: NSLocalizedString( + "This will permanently delete \"%@\".", + comment: "Project item delete confirmation message" + ), + contentView.projectItemDeleteTargetName + ) + ) + } + } + .alert(NSLocalizedString("Can’t Complete Action", comment: "Project item operation error alert title"), isPresented: contentView.$showProjectItemOperationErrorAlert) { + Button("OK", role: .cancel) { } + } message: { + Text(contentView.projectItemOperationErrorMessage) + } #if canImport(UIKit) .fileImporter( isPresented: contentView.$showIOSFileImporter, @@ -4130,7 +4221,7 @@ struct ContentView: View { case .trailing: proposed = startWidth - delta } - let clamped = min(max(proposed, 220), 520) + let clamped = min(max(proposed, minimumProjectSidebarWidth), maximumProjectSidebarWidth) projectSidebarWidth = Double(clamped) } .onEnded { _ in @@ -4239,7 +4330,13 @@ struct ContentView: View { onOpenFolder: { openProjectFolder() }, onToggleSupportedFilesOnly: { showSupportedProjectFilesOnly = $0 }, onOpenProjectFile: { openProjectFile(url: $0) }, - onRefreshTree: { refreshProjectBrowserState() } + onRefreshTree: { refreshProjectBrowserState() }, + onCreateProjectFile: { startProjectItemCreation(kind: .file, in: $0) }, + onCreateProjectFolder: { startProjectItemCreation(kind: .folder, in: $0) }, + onRenameProjectItem: { startProjectItemRename($0) }, + onDuplicateProjectItem: { duplicateProjectItem($0) }, + onDeleteProjectItem: { requestDeleteProjectItem($0) }, + revealURL: projectTreeRevealURL ) } @@ -4556,7 +4653,7 @@ struct ContentView: View { #endif }(), showLineNumbers: showLineNumbers, - showInvisibleCharacters: false, + showInvisibleCharacters: showInvisibleCharacters, highlightCurrentLine: effectiveHighlightCurrentLine, highlightMatchingBrackets: effectiveBracketHighlight, showScopeGuides: effectiveScopeGuides, @@ -4830,11 +4927,6 @@ struct ContentView: View { @ViewBuilder private var markdownPreviewPane: some View { VStack(alignment: .leading, spacing: 0) { - markdownPreviewHeader - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(editorSurfaceBackgroundStyle) - MarkdownPreviewWebView( html: markdownPreviewHTML( from: currentContent, diff --git a/Neon Vision Editor/UI/EditorTextView.swift b/Neon Vision Editor/UI/EditorTextView.swift index 9a3a631..5ba23dd 100644 --- a/Neon Vision Editor/UI/EditorTextView.swift +++ b/Neon Vision Editor/UI/EditorTextView.swift @@ -878,7 +878,7 @@ final class AcceptingTextView: NSTextView { private var vimObservers: [TextViewObserverToken] = [] private var activityObservers: [TextViewObserverToken] = [] private var didConfigureVimMode: Bool = false - private var didApplyDeepInvisibleDisable: Bool = false + private var lastAppliedInvisiblePreference: Bool? private var defaultsObserver: TextViewObserverToken? private let dropReadChunkSize = 64 * 1024 fileprivate var isApplyingDroppedContent: Bool = false @@ -938,7 +938,7 @@ final class AcceptingTextView: NSTextView { } override func draw(_ dirtyRect: NSRect) { - // Keep invisibles/control markers hard-disabled even during inactive-window redraw passes. + // Keep invisible/control marker rendering aligned with user preference on every redraw. forceDisableInvisibleGlyphRendering() super.draw(dirtyRect) } @@ -1250,9 +1250,8 @@ final class AcceptingTextView: NSTextView { } let sanitized = sanitizedPlainText(s) - // Ensure invisibles off after insertion - self.layoutManager?.showsInvisibleCharacters = false - self.layoutManager?.showsControlCharacters = false + // Keep invisible/control marker rendering aligned with current preference. + forceDisableInvisibleGlyphRendering() // Auto-indent by copying leading whitespace if sanitized == "\n" && autoIndentEnabled { @@ -1435,9 +1434,7 @@ final class AcceptingTextView: NSTextView { textStorage?.endEditing() isApplyingPaste = false - // Ensure invisibles are off after paste - self.layoutManager?.showsInvisibleCharacters = false - self.layoutManager?.showsControlCharacters = false + forceDisableInvisibleGlyphRendering() NotificationCenter.default.post(name: .pastedText, object: sanitized) didChangeText() @@ -1451,9 +1448,7 @@ final class AcceptingTextView: NSTextView { DispatchQueue.main.async { [weak self] in self?.isApplyingPaste = false - // Ensure invisibles are off after async paste - self?.layoutManager?.showsInvisibleCharacters = false - self?.layoutManager?.showsControlCharacters = false + self?.forceDisableInvisibleGlyphRendering() } // Enforce caret after paste (multiple ticks beats late selection changes) @@ -1532,42 +1527,25 @@ final class AcceptingTextView: NSTextView { private func forceDisableInvisibleGlyphRendering(deep: Bool = false) { let defaults = UserDefaults.standard - if defaults.bool(forKey: "NSShowAllInvisibles") || defaults.bool(forKey: "NSShowControlCharacters") { - defaults.set(false, forKey: "NSShowAllInvisibles") - defaults.set(false, forKey: "NSShowControlCharacters") + let shouldShow = defaults.bool(forKey: "SettingsShowInvisibleCharacters") + if defaults.bool(forKey: "NSShowAllInvisibles") != shouldShow { + defaults.set(shouldShow, forKey: "NSShowAllInvisibles") } - layoutManager?.showsInvisibleCharacters = false - layoutManager?.showsControlCharacters = false + if defaults.bool(forKey: "NSShowControlCharacters") != shouldShow { + defaults.set(shouldShow, forKey: "NSShowControlCharacters") + } + layoutManager?.showsInvisibleCharacters = shouldShow + layoutManager?.showsControlCharacters = shouldShow - guard deep, !didApplyDeepInvisibleDisable else { return } - didApplyDeepInvisibleDisable = true - - let selectors = [ - "setShowsInvisibleCharacters:", - "setShowsControlCharacters:", - "setDisplaysInvisibleCharacters:", - "setDisplaysControlCharacters:" - ] - for selectorName in selectors { - let selector = NSSelectorFromString(selectorName) - let value = NSNumber(value: false) - if responds(to: selector) { - _ = perform(selector, with: value) - } - if let lm = layoutManager, lm.responds(to: selector) { - _ = lm.perform(selector, with: value) - } + guard deep else { return } + if lastAppliedInvisiblePreference == shouldShow { + return } - if #available(macOS 12.0, *) { - if let tlm = value(forKey: "textLayoutManager") as? NSObject { - for selectorName in selectors { - let selector = NSSelectorFromString(selectorName) - if tlm.responds(to: selector) { - _ = tlm.perform(selector, with: NSNumber(value: false)) - } - } - } + lastAppliedInvisiblePreference = shouldShow + if let storage = textStorage { + layoutManager?.invalidateDisplay(forCharacterRange: NSRange(location: 0, length: storage.length)) } + needsDisplay = true } private func configureActivityObservers() { @@ -1968,42 +1946,18 @@ struct CustomTextEditor: NSViewRepresentable { } private func applyInvisibleCharacterPreference(_ textView: NSTextView) { - // Hard-disable invisible/control glyph rendering in editor text. + // Keep layout manager and defaults in sync with the user-facing setting. + let shouldShow = showInvisibleCharacters let defaults = UserDefaults.standard - defaults.set(false, forKey: "NSShowAllInvisibles") - defaults.set(false, forKey: "NSShowControlCharacters") - defaults.set(false, forKey: "SettingsShowInvisibleCharacters") - textView.layoutManager?.showsInvisibleCharacters = false - textView.layoutManager?.showsControlCharacters = false - let value = NSNumber(value: false) - let selectors = [ - "setShowsInvisibleCharacters:", - "setShowsControlCharacters:", - "setDisplaysInvisibleCharacters:", - "setDisplaysControlCharacters:" - ] - for selectorName in selectors { - let selector = NSSelectorFromString(selectorName) - if textView.responds(to: selector) { - let enabled = selectorName.contains("ControlCharacters") ? NSNumber(value: false) : value - textView.perform(selector, with: enabled) - } - if let layoutManager = textView.layoutManager, layoutManager.responds(to: selector) { - let enabled = selectorName.contains("ControlCharacters") ? NSNumber(value: false) : value - _ = layoutManager.perform(selector, with: enabled) - } - } - if #available(macOS 12.0, *) { - if let tlm = textView.value(forKey: "textLayoutManager") as? NSObject { - for selectorName in selectors { - let selector = NSSelectorFromString(selectorName) - if tlm.responds(to: selector) { - let enabled = selectorName.contains("ControlCharacters") ? NSNumber(value: false) : value - _ = tlm.perform(selector, with: enabled) - } - } - } + defaults.set(shouldShow, forKey: "NSShowAllInvisibles") + defaults.set(shouldShow, forKey: "NSShowControlCharacters") + defaults.set(shouldShow, forKey: "SettingsShowInvisibleCharacters") + textView.layoutManager?.showsInvisibleCharacters = shouldShow + textView.layoutManager?.showsControlCharacters = shouldShow + if let storage = textView.textStorage { + textView.layoutManager?.invalidateDisplay(forCharacterRange: NSRange(location: 0, length: storage.length)) } + textView.needsDisplay = true } private func sanitizedForExternalSet(_ input: String) -> String { @@ -4086,6 +4040,16 @@ struct CustomTextEditor: UIViewRepresentable { return UIFont.monospacedSystemFont(ofSize: targetSize, weight: .regular) } + private func applyInvisibleCharacterPreference(_ textView: UITextView) { + let shouldShow = showInvisibleCharacters + let defaults = UserDefaults.standard + defaults.set(shouldShow, forKey: "SettingsShowInvisibleCharacters") + defaults.set(shouldShow, forKey: "NSShowAllInvisibles") + defaults.set(shouldShow, forKey: "NSShowControlCharacters") + textView.layoutManager.invalidateDisplay(forCharacterRange: NSRange(location: 0, length: textView.textStorage.length)) + textView.setNeedsDisplay() + } + func makeUIView(context: Context) -> LineNumberedTextViewContainer { let container = LineNumberedTextViewContainer() let textView = container.textView @@ -4123,6 +4087,7 @@ struct CustomTextEditor: UIViewRepresentable { if #available(iOS 18.0, *) { textView.writingToolsBehavior = .none } + applyInvisibleCharacterPreference(textView) textView.backgroundColor = translucentBackgroundEnabled ? .clear : .systemBackground textView.setBracketAccessoryVisible(showKeyboardAccessoryBar) let shouldWrapText = isLineWrapEnabled && !isLargeFileMode @@ -4261,6 +4226,7 @@ struct CustomTextEditor: UIViewRepresentable { textView.writingToolsBehavior = .none } } + applyInvisibleCharacterPreference(textView) textView.typingAttributes[.foregroundColor] = baseColor if !showLineNumbers { uiView.lineNumberView.isHidden = true diff --git a/Neon Vision Editor/UI/NeonSettingsView.swift b/Neon Vision Editor/UI/NeonSettingsView.swift index 3cbb70e..3d2d63f 100644 --- a/Neon Vision Editor/UI/NeonSettingsView.swift +++ b/Neon Vision Editor/UI/NeonSettingsView.swift @@ -43,6 +43,7 @@ struct NeonSettingsView: View { @AppStorage("SettingsEditorFontSize") private var editorFontSize: Double = 14 @AppStorage("SettingsLineHeight") private var lineHeight: Double = 1.0 @AppStorage("SettingsAppearance") private var appearance: String = "system" + @AppStorage("SettingsAppLanguageCode") private var appLanguageCode: String = "system" @AppStorage("SettingsToolbarSymbolsColorMac") private var toolbarSymbolsColorMacRaw: String = "blue" #if os(iOS) @AppStorage("EnableTranslucentWindow") private var translucentWindow: Bool = true @@ -70,6 +71,7 @@ struct NeonSettingsView: View { @AppStorage("SettingsShowScopeGuides") private var showScopeGuides: Bool = false @AppStorage("SettingsHighlightScopeBackground") private var highlightScopeBackground: Bool = false @AppStorage("SettingsLineWrapEnabled") private var lineWrapEnabled: Bool = false + @AppStorage("SettingsShowInvisibleCharacters") private var showInvisibleCharacters: Bool = false @AppStorage("SettingsIndentStyle") private var indentStyle: String = "spaces" @AppStorage("SettingsIndentWidth") private var indentWidth: Int = 4 @AppStorage("SettingsAutoIndent") private var autoIndent: Bool = true @@ -149,6 +151,13 @@ struct NeonSettingsView: View { "csharp", "objective-c", "json", "xml", "yaml", "toml", "csv", "ini", "vim", "log", "ipynb", "markdown", "tex", "bash", "zsh", "powershell", "standard", "plain" ] + + private let appLanguageOptions: [String] = [ + "system", + "en", + "de", + "zh-Hans" + ] private var isCompactSettingsLayout: Bool { #if os(iOS) @@ -392,6 +401,28 @@ struct NeonSettingsView: View { String(format: NSLocalizedString(key, comment: ""), arguments: values) } + private func appLanguageLabel(for code: String) -> String { + switch code { + case "system": + return localized("Follow System") + case "de": + return "Deutsch" + case "zh-Hans": + return "简体中文" + default: + return "English" + } + } + + private func applyAppLanguagePreferenceIfNeeded() { + let defaults = UserDefaults.standard + if appLanguageCode == "system" { + defaults.removeObject(forKey: "AppleLanguages") + return + } + defaults.set([appLanguageCode], forKey: "AppleLanguages") + } + private var shouldShowSupportPurchaseControls: Bool { #if os(iOS) true @@ -450,6 +481,7 @@ struct NeonSettingsView: View { appUpdateManager.setAutoCheckEnabled(autoCheckForUpdates) appUpdateManager.setUpdateInterval(selectedUpdateInterval) appUpdateManager.setAutoDownloadEnabled(autoDownloadUpdates) + applyAppLanguagePreferenceIfNeeded() #if os(macOS) applyAppearanceImmediately() #endif @@ -459,6 +491,9 @@ struct NeonSettingsView: View { applyAppearanceImmediately() #endif } + .onChange(of: appLanguageCode) { _, _ in + applyAppLanguagePreferenceIfNeeded() + } .onChange(of: showScopeGuides) { _, enabled in if enabled && lineWrapEnabled { lineWrapEnabled = false @@ -641,6 +676,19 @@ struct NeonSettingsView: View { .pickerStyle(.segmented) } + iOSLabeledRow(LocalizedStringKey(localized("App Language"))) { + Picker("", selection: $appLanguageCode) { + ForEach(appLanguageOptions, id: \.self) { languageCode in + Text(appLanguageLabel(for: languageCode)).tag(languageCode) + } + } + .pickerStyle(.menu) + } + + Text(localized("Language changes apply after relaunch.")) + .font(Typography.footnote) + .foregroundStyle(.secondary) + if supportsTranslucency { iOSToggleRow(LocalizedStringKey(localized("Translucent Window")), isOn: $translucentWindow) } @@ -669,9 +717,26 @@ struct NeonSettingsView: View { Text(localized("Light")).tag("light") Text(localized("Dark")).tag("dark") } - .pickerStyle(.segmented) + .pickerStyle(.segmented) } + HStack(alignment: .center, spacing: UI.space12) { + Text(localized("App Language")) + .frame(width: isCompactSettingsLayout ? nil : standardLabelWidth, alignment: .leading) + Picker("", selection: $appLanguageCode) { + ForEach(appLanguageOptions, id: \.self) { languageCode in + Text(appLanguageLabel(for: languageCode)).tag(languageCode) + } + } + .pickerStyle(.menu) + .frame(maxWidth: isCompactSettingsLayout ? .infinity : 240, alignment: .leading) + } + + Text(localized("Language changes apply after relaunch.")) + .font(Typography.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + HStack(alignment: .center, spacing: UI.space12) { Text(localized("Toolbar Symbols")) .frame(width: isCompactSettingsLayout ? nil : standardLabelWidth, alignment: .leading) @@ -1189,13 +1254,14 @@ struct NeonSettingsView: View { Toggle("Show Scope Guides (Non-Swift)", isOn: $showScopeGuides) Toggle("Highlight Scoped Region", isOn: $highlightScopeBackground) Toggle("Line Wrap", isOn: $lineWrapEnabled) + Toggle("Show Invisible Characters", isOn: $showInvisibleCharacters) Text("When Line Wrap is enabled, scope guides/scoped region are turned off to avoid layout conflicts.") .font(Typography.footnote) .foregroundStyle(.secondary) Text("Scope guides are intended for non-Swift languages. Swift favors matching-token highlight.") .font(Typography.footnote) .foregroundStyle(.secondary) - Text("Invisible character markers are disabled to avoid whitespace glyph artifacts.") + Text("Invisible character markers may affect rendering performance on very large files.") .font(Typography.footnote) .foregroundStyle(.secondary) } @@ -1240,13 +1306,14 @@ struct NeonSettingsView: View { Toggle("Show Scope Guides (Non-Swift)", isOn: $showScopeGuides) Toggle("Highlight Scoped Region", isOn: $highlightScopeBackground) Toggle("Line Wrap", isOn: $lineWrapEnabled) + Toggle("Show Invisible Characters", isOn: $showInvisibleCharacters) Text("When Line Wrap is enabled, scope guides/scoped region are turned off to avoid layout conflicts.") .font(Typography.footnote) .foregroundStyle(.secondary) Text("Scope guides are intended for non-Swift languages. Swift favors matching-token highlight.") .font(Typography.footnote) .foregroundStyle(.secondary) - Text("Invisible character markers are disabled to avoid whitespace glyph artifacts.") + Text("Invisible character markers may affect rendering performance on very large files.") .font(Typography.footnote) .foregroundStyle(.secondary) } diff --git a/Neon Vision Editor/UI/SidebarViews.swift b/Neon Vision Editor/UI/SidebarViews.swift index bc52827..b28565c 100644 --- a/Neon Vision Editor/UI/SidebarViews.swift +++ b/Neon Vision Editor/UI/SidebarViews.swift @@ -361,7 +361,14 @@ struct ProjectStructureSidebarView: View { let onToggleSupportedFilesOnly: (Bool) -> Void let onOpenProjectFile: (URL) -> Void let onRefreshTree: () -> Void + let onCreateProjectFile: (URL?) -> Void + let onCreateProjectFolder: (URL?) -> Void + let onRenameProjectItem: (URL) -> Void + let onDuplicateProjectItem: (URL) -> Void + let onDeleteProjectItem: (URL) -> Void + let revealURL: URL? @State private var expandedDirectories: Set = [] + @State private var hoveredNodeID: String? = nil @Environment(\.colorScheme) private var colorScheme #if os(macOS) @AppStorage("SettingsMacTranslucencyMode") private var macTranslucencyModeRaw: String = "balanced" @@ -372,59 +379,91 @@ struct ProjectStructureSidebarView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { if showsSidebarActionsRow { - HStack { + VStack(alignment: .leading, spacing: isCompactDensity ? 8 : 10) { if showsInlineSidebarTitle { - Text("Project Structure") + Text(NSLocalizedString("Project Structure", comment: "Project structure sidebar title")) .font(.system(size: isCompactDensity ? 19 : 20, weight: .semibold)) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) } - Spacer() - Button(action: onOpenFolder) { - Image(systemName: "folder.badge.plus") - } - .buttonStyle(.borderless) - .help("Open Folder…") + HStack(spacing: isCompactDensity ? 10 : 12) { + Button(action: onOpenFolder) { + Image(systemName: "folder") + } + .buttonStyle(.borderless) + .help(NSLocalizedString("Open Folder…", comment: "Project sidebar open folder action")) + .accessibilityLabel(NSLocalizedString("Open folder", comment: "Project sidebar open folder accessibility label")) + .accessibilityHint(NSLocalizedString("Select a project folder to show in the sidebar", comment: "Project sidebar open folder accessibility hint")) - Button(action: onOpenFile) { - Image(systemName: "doc.badge.plus") - } - .buttonStyle(.borderless) - .help("Open File…") + Button(action: onOpenFile) { + Image(systemName: "doc") + } + .buttonStyle(.borderless) + .help(NSLocalizedString("Open File…", comment: "Project sidebar open file action")) + .accessibilityLabel(NSLocalizedString("Open file", comment: "Project sidebar open file accessibility label")) + .accessibilityHint(NSLocalizedString("Opens a file from disk", comment: "Project sidebar open file accessibility hint")) - Button(action: onRefreshTree) { - Image(systemName: "arrow.clockwise") - } - .buttonStyle(.borderless) - .help("Refresh Folder Tree") + Menu { + Button { + onCreateProjectFile(nil) + } label: { + Label(NSLocalizedString("New File", comment: "Project sidebar create file action"), systemImage: "doc.badge.plus") + } - Menu { - Button { - onToggleSupportedFilesOnly(!showSupportedFilesOnly) + Button { + onCreateProjectFolder(nil) + } label: { + Label(NSLocalizedString("New Folder", comment: "Project sidebar create folder action"), systemImage: "folder.badge.plus") + } } label: { - Label( - "Show Supported Files Only", - systemImage: showSupportedFilesOnly ? "checkmark.circle.fill" : "circle" - ) + Image(systemName: "plus") } - Divider() - Picker("Density", selection: $sidebarDensityRaw) { - Text("Compact").tag(SidebarDensity.compact.rawValue) - Text("Comfortable").tag(SidebarDensity.comfortable.rawValue) + .buttonStyle(.borderless) + .help(NSLocalizedString("Create in Project Root", comment: "Project sidebar create action")) + .accessibilityLabel(NSLocalizedString("Create project item", comment: "Project sidebar create accessibility label")) + .accessibilityHint(NSLocalizedString("Creates a new file or folder in the project root", comment: "Project sidebar create accessibility hint")) + + Button(action: onRefreshTree) { + Image(systemName: "arrow.clockwise") } - Toggle("Auto-collapse Deep Folders", isOn: $autoCollapseDeepFolders) - Divider() - Button("Expand All") { - expandAllDirectories() + .buttonStyle(.borderless) + .help(NSLocalizedString("Refresh Folder Tree", comment: "Project sidebar refresh tree action")) + .accessibilityLabel(NSLocalizedString("Refresh project tree", comment: "Project sidebar refresh accessibility label")) + .accessibilityHint(NSLocalizedString("Reloads files and folders from disk", comment: "Project sidebar refresh accessibility hint")) + + Menu { + Button { + onToggleSupportedFilesOnly(!showSupportedFilesOnly) + } label: { + Label( + NSLocalizedString("Show Supported Files Only", comment: "Project sidebar supported files filter label"), + systemImage: showSupportedFilesOnly ? "checkmark.circle.fill" : "circle" + ) + } + Divider() + Picker(NSLocalizedString("Density", comment: "Project sidebar density picker label"), selection: $sidebarDensityRaw) { + Text(NSLocalizedString("Compact", comment: "Project sidebar compact density")).tag(SidebarDensity.compact.rawValue) + Text(NSLocalizedString("Comfortable", comment: "Project sidebar comfortable density")).tag(SidebarDensity.comfortable.rawValue) + } + Toggle(NSLocalizedString("Auto-collapse Deep Folders", comment: "Project sidebar auto-collapse deep folders toggle"), isOn: $autoCollapseDeepFolders) + Divider() + Button(NSLocalizedString("Expand All", comment: "Project sidebar expand all action")) { + expandAllDirectories() + } + Button(NSLocalizedString("Collapse All", comment: "Project sidebar collapse all action")) { + collapseAllDirectories() + } + } label: { + Image(systemName: "arrow.up.arrow.down.circle") } - Button("Collapse All") { - collapseAllDirectories() - } - } label: { - Image(systemName: "arrow.up.arrow.down.circle") + .buttonStyle(.borderless) + .help(NSLocalizedString("Expand or Collapse All", comment: "Project sidebar expand/collapse help")) + .accessibilityLabel(NSLocalizedString("Expand or collapse all folders", comment: "Project sidebar expand/collapse accessibility label")) + .accessibilityHint(NSLocalizedString("Expands or collapses all folders in the project tree", comment: "Project sidebar expand/collapse accessibility hint")) + + Spacer(minLength: 0) } - .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, headerHorizontalPadding) .padding(.top, headerTopPadding) @@ -440,6 +479,18 @@ struct ProjectStructureSidebarView: View { .foregroundStyle(.secondary) .lineLimit(isCompactDensity ? 1 : 2) .textSelection(.enabled) + .contextMenu { + Button { + onCreateProjectFile(rootFolderURL) + } label: { + Label(NSLocalizedString("New File", comment: "Project sidebar create file action"), systemImage: "doc.badge.plus") + } + Button { + onCreateProjectFolder(rootFolderURL) + } label: { + Label(NSLocalizedString("New Folder", comment: "Project sidebar create folder action"), systemImage: "folder.badge.plus") + } + } .padding(.horizontal, headerHorizontalPadding) .padding(.top, showsSidebarActionsRow ? 0 : headerTopPadding) .padding(.bottom, headerPathBottomPadding) @@ -447,12 +498,12 @@ struct ProjectStructureSidebarView: View { List { if rootFolderURL == nil { - Text("No folder selected") + Text(NSLocalizedString("No folder selected", comment: "Project sidebar empty state without root folder")) .foregroundColor(.secondary) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } else if nodes.isEmpty { - Text("Folder is empty") + Text(NSLocalizedString("Folder is empty", comment: "Project sidebar empty state for selected folder")) .foregroundColor(.secondary) .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -465,11 +516,34 @@ struct ProjectStructureSidebarView: View { .listStyle(platformListStyle) .scrollContentBackground(.hidden) .background(Color.clear) + .contextMenu { + if let rootFolderURL { + Button { + onCreateProjectFile(rootFolderURL) + } label: { + Label(NSLocalizedString("New File", comment: "Project sidebar create file action"), systemImage: "doc.badge.plus") + } + Button { + onCreateProjectFolder(rootFolderURL) + } label: { + Label(NSLocalizedString("New Folder", comment: "Project sidebar create folder action"), systemImage: "folder.badge.plus") + } + } + } } .padding(sidebarOuterPadding) .background(sidebarContainerShape.fill(sidebarSurfaceFill)) .overlay(sidebarContainerBorderOverlay) .clipShape(sidebarContainerShape) + .onAppear { + revealTargetIfNeeded() + } + .onChange(of: revealPath) { _, _ in + revealTargetIfNeeded() + } + .onChange(of: nodes.count) { _, _ in + revealTargetIfNeeded() + } #if os(macOS) .overlay(alignment: boundaryEdge == .leading ? .leading : .trailing) { if boundaryEdge != nil { @@ -604,6 +678,7 @@ struct ProjectStructureSidebarView: View { private func projectNodeView(_ node: ProjectTreeNode, level: Int) -> AnyView { if node.isDirectory { + let isHovered = hoveredNodeID == node.id return AnyView( DisclosureGroup(isExpanded: Binding( get: { expandedDirectories.contains(node.id) }, @@ -631,16 +706,65 @@ struct ProjectStructureSidebarView: View { .padding(.trailing, rowHorizontalPadding) .padding(.leading, directoryRowContentLeadingPadding) .frame(maxWidth: .infinity, alignment: .leading) - .background(rowChrome(isSelected: false)) + .background(rowChrome(isSelected: false, isHovered: isHovered)) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityLabel( + Text( + String( + format: NSLocalizedString("Folder %@", comment: "Project sidebar folder accessibility label"), + node.url.lastPathComponent + ) + ) + ) } .padding(.leading, directoryRowLeadingInset(for: level)) .listRowInsets(rowInsets) .listRowBackground(Color.clear) .listRowSeparator(.hidden) + .contextMenu { + Button { + onCreateProjectFile(node.url) + } label: { + Label(NSLocalizedString("New File", comment: "Project sidebar create file action"), systemImage: "doc.badge.plus") + } + Button { + onCreateProjectFolder(node.url) + } label: { + Label(NSLocalizedString("New Folder", comment: "Project sidebar create folder action"), systemImage: "folder.badge.plus") + } + Divider() + Button { + onRenameProjectItem(node.url) + } label: { + Label(NSLocalizedString("Rename", comment: "Project sidebar rename action"), systemImage: "pencil") + } + Button { + onDuplicateProjectItem(node.url) + } label: { + Label(NSLocalizedString("Duplicate", comment: "Project sidebar duplicate action"), systemImage: "plus.square.on.square") + } + Divider() + Button(role: .destructive) { + onDeleteProjectItem(node.url) + } label: { + Label(NSLocalizedString("Delete", comment: "Project sidebar delete action"), systemImage: "trash") + } + } +#if os(macOS) + .onHover { hovering in + if hovering { + hoveredNodeID = node.id + } else if hoveredNodeID == node.id { + hoveredNodeID = nil + } + } +#endif ) } else { let style = fileIconStyle(for: node.url) let isSelected = selectedFileURL?.standardizedFileURL == node.url.standardizedFileURL + let isHovered = hoveredNodeID == node.id return AnyView( Button { onOpenProjectFile(node.url) @@ -663,13 +787,60 @@ struct ProjectStructureSidebarView: View { .padding(.vertical, rowVerticalPadding) .padding(.horizontal, rowHorizontalPadding) .frame(maxWidth: .infinity, alignment: .leading) - .background(rowChrome(isSelected: isSelected)) + .background(rowChrome(isSelected: isSelected, isHovered: isHovered)) + .contentShape(Rectangle()) } .buttonStyle(.plain) .padding(.leading, CGFloat(level) * levelIndent) .listRowInsets(rowInsets) .listRowBackground(Color.clear) .listRowSeparator(.hidden) + .contextMenu { + Button { + onCreateProjectFile(node.url.deletingLastPathComponent()) + } label: { + Label(NSLocalizedString("New File Here", comment: "Project sidebar create file in same directory action"), systemImage: "doc.badge.plus") + } + Button { + onCreateProjectFolder(node.url.deletingLastPathComponent()) + } label: { + Label(NSLocalizedString("New Folder Here", comment: "Project sidebar create folder in same directory action"), systemImage: "folder.badge.plus") + } + Divider() + Button { + onRenameProjectItem(node.url) + } label: { + Label(NSLocalizedString("Rename", comment: "Project sidebar rename action"), systemImage: "pencil") + } + Button { + onDuplicateProjectItem(node.url) + } label: { + Label(NSLocalizedString("Duplicate", comment: "Project sidebar duplicate action"), systemImage: "plus.square.on.square") + } + Divider() + Button(role: .destructive) { + onDeleteProjectItem(node.url) + } label: { + Label(NSLocalizedString("Delete", comment: "Project sidebar delete action"), systemImage: "trash") + } + } + .accessibilityLabel( + Text( + String( + format: NSLocalizedString("File %@", comment: "Project sidebar file accessibility label"), + node.url.lastPathComponent + ) + ) + ) +#if os(macOS) + .onHover { hovering in + if hovering { + hoveredNodeID = node.id + } else if hoveredNodeID == node.id { + hoveredNodeID = nil + } + } +#endif ) } } @@ -681,15 +852,15 @@ struct ProjectStructureSidebarView: View { private var isCompactDensity: Bool { sidebarDensity == .compact } private var levelIndent: CGFloat { - isCompactDensity ? 8 : 11 + isCompactDensity ? 10 : 13 } private var rowVerticalPadding: CGFloat { - isCompactDensity ? 6 : 8 + isCompactDensity ? 7 : 9 } private var rowHorizontalPadding: CGFloat { - isCompactDensity ? 10 : 12 + isCompactDensity ? 10 : 13 } private var directoryRowContentSpacing: CGFloat { @@ -702,9 +873,9 @@ struct ProjectStructureSidebarView: View { private var directoryRowContentLeadingPadding: CGFloat { #if os(macOS) - isCompactDensity ? 0 : 1 + isCompactDensity ? 1 : 2 #else - isCompactDensity ? 3 : 4 + isCompactDensity ? 4 : 5 #endif } @@ -725,7 +896,7 @@ struct ProjectStructureSidebarView: View { } private var rowInsets: EdgeInsets { - EdgeInsets(top: 2, leading: isCompactDensity ? 8 : 10, bottom: 2, trailing: isCompactDensity ? 8 : 10) + EdgeInsets(top: 3, leading: isCompactDensity ? 11 : 13, bottom: 3, trailing: isCompactDensity ? 10 : 12) } private var showsInlineSidebarTitle: Bool { @@ -743,9 +914,9 @@ struct ProjectStructureSidebarView: View { private func directoryRowLeadingInset(for level: Int) -> CGFloat { let baseInset: CGFloat #if os(macOS) - baseInset = level == 0 ? (isCompactDensity ? 6 : 8) : 0 + baseInset = level == 0 ? (isCompactDensity ? 12 : 14) : 0 #else - baseInset = level == 0 ? (isCompactDensity ? 4 : 6) : 0 + baseInset = level == 0 ? (isCompactDensity ? 8 : 10) : 0 #endif return baseInset + CGFloat(level) * levelIndent } @@ -758,20 +929,66 @@ struct ProjectStructureSidebarView: View { colorScheme == .dark ? .white : .primary } - private func rowChrome(isSelected: Bool) -> some View { + private func rowChrome(isSelected: Bool, isHovered: Bool) -> some View { RoundedRectangle(cornerRadius: isCompactDensity ? 12 : 14, style: .continuous) - .fill(isSelected ? selectedRowFill : unselectedRowFill) + .fill(rowFill(isSelected: isSelected, isHovered: isHovered)) + } + + private func rowFill(isSelected: Bool, isHovered: Bool) -> Color { + if isSelected { return selectedRowFill } + if isHovered { return hoveredRowFill } + return unselectedRowFill } private var selectedRowFill: Color { if colorScheme == .dark { - return Color.accentColor.opacity(0.42) + return Color.accentColor.opacity(0.48) } - return Color.accentColor.opacity(0.18) + return Color.accentColor.opacity(0.22) + } + + private var hoveredRowFill: Color { + if colorScheme == .dark { + return Color.white.opacity(0.08) + } + return Color.black.opacity(0.07) } private var unselectedRowFill: Color { - colorScheme == .dark ? Color.white.opacity(0.02) : Color.black.opacity(0.018) + colorScheme == .dark ? Color.white.opacity(0.028) : Color.black.opacity(0.024) + } + + private var revealPath: String? { + revealURL?.standardizedFileURL.path + } + + private func revealTargetIfNeeded() { + guard let revealPath else { return } + guard let pathIDs = directoryPathIDs(for: revealPath, in: nodes) else { return } + expandedDirectories.formUnion(pathIDs) + } + + private func directoryPathIDs(for targetPath: String, in treeNodes: [ProjectTreeNode]) -> [String]? { + for node in treeNodes { + if let path = directoryPathIDs(for: targetPath, node: node) { + return path + } + } + return nil + } + + private func directoryPathIDs(for targetPath: String, node: ProjectTreeNode) -> [String]? { + let nodePath = node.url.standardizedFileURL.path + if nodePath == targetPath { + return node.isDirectory ? [node.id] : [] + } + guard node.isDirectory else { return nil } + for child in node.children { + if let childPath = directoryPathIDs(for: targetPath, node: child) { + return [node.id] + childPath + } + } + return nil } private func fileIconStyle(for url: URL) -> FileIconStyle { diff --git a/Neon Vision Editor/de.lproj/Localizable.strings b/Neon Vision Editor/de.lproj/Localizable.strings index 2742e32..d08e681 100644 --- a/Neon Vision Editor/de.lproj/Localizable.strings +++ b/Neon Vision Editor/de.lproj/Localizable.strings @@ -206,7 +206,6 @@ "Copy Markdown preview HTML" = "HTML der Markdown-Vorschau kopieren"; "Copy Markdown" = "Markdown kopieren"; "Copy Markdown source" = "Markdown-Quelltext kopieren"; -"More" = "Mehr"; "More Markdown preview actions" = "Weitere Aktionen für die Markdown-Vorschau"; "How To Connect" = "So verbindest du dich"; "Copied the broker attach code." = "Der Broker-Attach-Code wurde kopiert."; @@ -386,12 +385,7 @@ "Insert Template" = "Vorlage einfügen"; "Enable Wrap / Disable Wrap" = "Zeilenumbruch aktivieren/deaktivieren"; "Settings" = "Einstellungen"; -"Markdown Preview" = "Markdown-Vorschau"; "Toggle Markdown Preview" = "Markdown-Vorschau ein-/ausblenden"; -"Default" = "Standard"; -"Docs" = "Doks"; -"Article" = "Artikel"; -"Compact" = "Kompakt"; "Preview Style" = "Vorschau-Stil"; "Markdown Preview Template" = "Markdown-Vorlage für Vorschau"; "Open" = "Öffnen"; @@ -407,7 +401,6 @@ "Clear" = "Leeren"; "Template" = "Vorlage"; "Toggle Sidebar (Cmd+Opt+S)" = "Seitenleiste ein-/ausblenden (Cmd+Opt+S)"; -"Sidebar" = "Seitenleiste"; "Project" = "Projekt"; "Brackets" = "Klammern"; "Code Completion" = "Code-Vervollständigung"; @@ -430,3 +423,45 @@ "Show Supported Files Only" = "Nur unterstützte Dateien anzeigen"; "Enable Vim Mode" = "Vim-Modus aktivieren"; "Requires a hardware keyboard on iPad. Escape returns to Normal mode, and the status line shows INSERT or NORMAL while Vim mode is active." = "Erfordert eine Hardware-Tastatur auf dem iPad. Escape wechselt in den Normal-Modus, und die Statusleiste zeigt bei aktivem Vim-Modus INSERT oder NORMAL an."; +"App Language" = "App-Sprache"; +"Language changes apply after relaunch." = "Sprachänderungen werden sofort wirksam."; +"Show Invisible Characters" = "Unsichtbare Zeichen anzeigen"; +"Invisible character markers may affect rendering performance on very large files." = "Markierungen für unsichtbare Zeichen können bei sehr großen Dateien die Darstellungsleistung beeinträchtigen."; +"Use a valid name without slashes." = "Verwende einen gültigen Namen ohne Schrägstriche."; +"An item with this name already exists." = "Ein Element mit diesem Namen existiert bereits."; +"The selected item no longer exists." = "Das ausgewählte Element existiert nicht mehr."; +"Choose a name for the new item." = "Wähle einen Namen für das neue Element."; +"Rename Item" = "Element umbenennen"; +"Name" = "Name"; +"Enter a new name." = "Gib einen neuen Namen ein."; +"Delete Item?" = "Element löschen?"; +"This will permanently delete \"%@\"." = "Dadurch wird \"%@\" dauerhaft gelöscht."; +"Can’t Complete Action" = "Aktion kann nicht abgeschlossen werden"; +"File name" = "Dateiname"; +"Folder name" = "Ordnername"; +"Duplicate" = "Duplizieren"; +"New File" = "Neue Datei"; +"New Folder" = "Neuer Ordner"; +"Delete" = "Löschen"; +"File %@" = "Datei %@"; +"Folder %@" = "Ordner %@"; +"New File Here" = "Neue Datei hier"; +"New Folder Here" = "Neuer Ordner hier"; +"Create in Project Root" = "Im Projektstamm erstellen"; +"Auto-collapse Deep Folders" = "Tiefe Ordner automatisch einklappen"; +"Expand All" = "Alle ausklappen"; +"Collapse All" = "Alle einklappen"; +"Density" = "Dichte"; +"Comfortable" = "Komfortabel"; +"Expand or Collapse All" = "Alle aus- oder einklappen"; +"Open folder" = "Ordner öffnen"; +"Select a project folder to show in the sidebar" = "Wähle einen Projektordner, der in der Seitenleiste angezeigt wird"; +"Open file" = "Datei öffnen"; +"Opens a file from disk" = "Öffnet eine Datei vom Datenträger"; +"Create project item" = "Projektelement erstellen"; +"Creates a new file or folder in the project root" = "Erstellt eine neue Datei oder einen neuen Ordner im Projektstamm"; +"Refresh project tree" = "Projektbaum aktualisieren"; +"Reloads files and folders from disk" = "Lädt Dateien und Ordner vom Datenträger neu"; +"Expand or collapse all folders" = "Alle Ordner aus- oder einklappen"; +"Expands or collapses all folders in the project tree" = "Klappt alle Ordner im Projektbaum aus oder ein"; +"Markdown Preview Export Options" = "Markdown-Vorschau-Exportoptionen"; diff --git a/Neon Vision Editor/en.lproj/Localizable.strings b/Neon Vision Editor/en.lproj/Localizable.strings index a08639b..dd44a74 100644 --- a/Neon Vision Editor/en.lproj/Localizable.strings +++ b/Neon Vision Editor/en.lproj/Localizable.strings @@ -185,7 +185,6 @@ "Copy Markdown preview HTML" = "Copy Markdown preview HTML"; "Copy Markdown" = "Copy Markdown"; "Copy Markdown source" = "Copy Markdown source"; -"More" = "More"; "More Markdown preview actions" = "More Markdown preview actions"; "How To Connect" = "How To Connect"; "Copied the broker attach code." = "Copied the broker attach code."; @@ -331,3 +330,50 @@ "Show Supported Files Only" = "Show Supported Files Only"; "Enable Vim Mode" = "Enable Vim Mode"; "Requires a hardware keyboard on iPad. Escape returns to Normal mode, and the status line shows INSERT or NORMAL while Vim mode is active." = "Requires a hardware keyboard on iPad. Escape returns to Normal mode, and the status line shows INSERT or NORMAL while Vim mode is active."; +"App Language" = "App Language"; +"Language changes apply after relaunch." = "Language changes apply immediately."; +"Show Invisible Characters" = "Show Invisible Characters"; +"Invisible character markers may affect rendering performance on very large files." = "Invisible character markers may affect rendering performance on very large files."; +"Use a valid name without slashes." = "Use a valid name without slashes."; +"An item with this name already exists." = "An item with this name already exists."; +"The selected item no longer exists." = "The selected item no longer exists."; +"Choose a name for the new item." = "Choose a name for the new item."; +"Rename Item" = "Rename Item"; +"Name" = "Name"; +"Enter a new name." = "Enter a new name."; +"Delete Item?" = "Delete Item?"; +"This will permanently delete \"%@\"." = "This will permanently delete \"%@\"."; +"Can’t Complete Action" = "Can’t Complete Action"; +"File name" = "File name"; +"Folder name" = "Folder name"; +"Duplicate" = "Duplicate"; +"New File" = "New File"; +"New Folder" = "New Folder"; +"Rename" = "Rename"; +"Delete" = "Delete"; +"File %@" = "File %@"; +"Folder %@" = "Folder %@"; +"New File Here" = "New File Here"; +"New Folder Here" = "New Folder Here"; +"Create in Project Root" = "Create in Project Root"; +"Auto-collapse Deep Folders" = "Auto-collapse Deep Folders"; +"Expand All" = "Expand All"; +"Collapse All" = "Collapse All"; +"Density" = "Density"; +"Comfortable" = "Comfortable"; +"Expand or Collapse All" = "Expand or Collapse All"; +"Open folder" = "Open folder"; +"Select a project folder to show in the sidebar" = "Select a project folder to show in the sidebar"; +"Open file" = "Open file"; +"Opens a file from disk" = "Opens a file from disk"; +"Create project item" = "Create project item"; +"Creates a new file or folder in the project root" = "Creates a new file or folder in the project root"; +"Refresh project tree" = "Refresh project tree"; +"Reloads files and folders from disk" = "Reloads files and folders from disk"; +"Expand or collapse all folders" = "Expand or collapse all folders"; +"Expands or collapses all folders in the project tree" = "Expands or collapses all folders in the project tree"; +"Open Folder…" = "Open Folder…"; +"Open File…" = "Open File…"; +"Preview Style" = "Preview Style"; +"Markdown Preview Template" = "Markdown Preview Template"; +"Markdown Preview Export Options" = "Markdown Preview Export Options"; diff --git a/Neon Vision Editor/zh-Hans.lproj/Localizable.strings b/Neon Vision Editor/zh-Hans.lproj/Localizable.strings new file mode 100644 index 0000000..3b519c0 --- /dev/null +++ b/Neon Vision Editor/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,118 @@ +"General" = "通用"; +"Editor" = "编辑器"; +"Templates" = "模板"; +"Themes" = "主题"; +"More" = "更多"; +"AI" = "AI"; +"Updates" = "更新"; +"Remote" = "远程"; + +"Window" = "窗口"; +"Window behavior, startup defaults, and confirmation preferences." = "窗口行为、启动默认值与确认选项。"; +"Open in Tabs" = "在标签页中打开"; +"Follow System" = "跟随系统"; +"Always" = "始终"; +"Never" = "从不"; +"Appearance" = "外观"; +"System" = "系统"; +"Light" = "浅色"; +"Dark" = "深色"; +"App Language" = "应用语言"; +"Language changes apply after relaunch." = "语言变更会立即生效。"; +"Toolbar Symbols" = "工具栏图标"; +"Blue" = "蓝色"; +"Dark Gray" = "深灰"; +"Black" = "黑色"; +"Translucent Window" = "半透明窗口"; +"Translucency Mode" = "半透明模式"; + +"Editor Font" = "编辑器字体"; +"Use System Font" = "使用系统字体"; +"Font" = "字体"; +"Font Size" = "字号"; +"Line Height" = "行高"; +"%lld pt" = "%lld pt"; + +"Startup" = "启动"; +"Open with Blank Document" = "以空白文档启动"; +"Reopen Last Session" = "重新打开上次会话"; +"Default New File Language" = "新文件默认语言"; +"Tip: Enable only one startup mode to keep app launch behavior predictable." = "提示:仅启用一种启动模式可保持启动行为可预测。"; + +"Confirmations" = "确认"; +"Confirm Before Closing Dirty Tab" = "关闭未保存标签前确认"; +"Confirm Before Clearing Editor" = "清空编辑器前确认"; + +"Display" = "显示"; +"Show Line Numbers" = "显示行号"; +"Highlight Current Line" = "高亮当前行"; +"Highlight Matching Brackets" = "高亮匹配括号"; +"Show Scope Guides (Non-Swift)" = "显示作用域引导线(非 Swift)"; +"Highlight Scoped Region" = "高亮作用域区域"; +"Line Wrap" = "自动换行"; +"Show Invisible Characters" = "显示不可见字符"; +"When Line Wrap is enabled, scope guides/scoped region are turned off to avoid layout conflicts." = "启用自动换行时,将关闭作用域引导线/作用域区域高亮以避免布局冲突。"; +"Scope guides are intended for non-Swift languages. Swift favors matching-token highlight." = "作用域引导线主要用于非 Swift 语言;Swift 更适合使用匹配标记高亮。"; +"Invisible character markers may affect rendering performance on very large files." = "在超大文件中显示不可见字符可能影响渲染性能。"; + +"Indentation" = "缩进"; +"Indent Style" = "缩进样式"; +"Spaces" = "空格"; +"Tabs" = "制表符"; +"Indent Width: %lld" = "缩进宽度:%lld"; + +"Layout" = "布局"; +"Project Navigator Position" = "项目导航位置"; +"Left" = "左侧"; +"Right" = "右侧"; + +"Section" = "分区"; +"Basics" = "基础"; +"Behavior" = "行为"; + +"Support" = "支持"; +"Support Development" = "支持开发"; +"Privacy Policy" = "隐私政策"; +"Terms of Use" = "使用条款"; +"Unavailable" = "不可用"; +"Loading..." = "加载中..."; + +"Can’t Open File" = "无法打开文件"; +"The file \"%@\" is not supported and can’t be opened." = "文件“%@”不受支持,无法打开。"; +"Use a valid name without slashes." = "请使用不含斜杠的有效名称。"; +"An item with this name already exists." = "已存在同名项目。"; +"The selected item no longer exists." = "所选项目已不存在。"; +"Choose a name for the new item." = "请为新项目输入名称。"; +"Rename Item" = "重命名项目"; +"Name" = "名称"; +"Enter a new name." = "请输入新名称。"; +"Delete Item?" = "删除项目?"; +"This will permanently delete \"%@\"." = "这将永久删除“%@”。"; +"Can’t Complete Action" = "无法完成操作"; +"File name" = "文件名"; +"Folder name" = "文件夹名"; +"Duplicate" = "复制"; +"New File Here" = "在此新建文件"; +"New Folder Here" = "在此新建文件夹"; +"Create in Project Root" = "在项目根目录创建"; +"Auto-collapse Deep Folders" = "自动折叠深层文件夹"; +"Expand All" = "全部展开"; +"Collapse All" = "全部折叠"; +"Density" = "密度"; +"Comfortable" = "舒适"; +"Expand or Collapse All" = "全部展开或折叠"; +"Open folder" = "打开文件夹"; +"Select a project folder to show in the sidebar" = "选择要在侧边栏显示的项目文件夹"; +"Open file" = "打开文件"; +"Opens a file from disk" = "从磁盘打开文件"; +"Create project item" = "创建项目项"; +"Creates a new file or folder in the project root" = "在项目根目录中新建文件或文件夹"; +"Refresh project tree" = "刷新项目树"; +"Reloads files and folders from disk" = "从磁盘重新加载文件和文件夹"; +"Expand or collapse all folders" = "展开或折叠所有文件夹"; +"Expands or collapses all folders in the project tree" = "展开或折叠项目树中的所有文件夹"; +"Open Folder…" = "打开文件夹…"; +"Open File…" = "打开文件…"; +"Preview Style" = "预览样式"; +"Markdown Preview Template" = "Markdown 预览模板"; +"Markdown Preview Export Options" = "Markdown 预览导出选项";