Release v0.6.1 updates and changelog

This commit is contained in:
h3p 2026-04-16 12:37:03 +02:00
parent 696a8dde9d
commit 493be746da
No known key found for this signature in database
14 changed files with 1370 additions and 168 deletions

View file

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

View file

@ -149,6 +149,7 @@
en,
Base,
de,
"zh-Hans",
);
mainGroup = 98EAE62A2E5F15E80050E579;
minimizedProjectReferenceProxies = 1;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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("Cant 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,

View file

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

View file

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

View file

@ -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<String> = []
@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 {

View file

@ -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.";
"Cant 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";

View file

@ -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 \"%@\".";
"Cant Complete Action" = "Cant 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";

View file

@ -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..." = "加载中...";
"Cant Open File" = "无法打开文件";
"The file \"%@\" is not supported and cant 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 \"%@\"." = "这将永久删除“%@”。";
"Cant 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 预览导出选项";