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 ### Migration
- None. - 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 ## [v0.6.0] - 2026-03-30
### Why Upgrade ### Why Upgrade

View file

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

View file

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ObjectiveC.runtime
#if canImport(FoundationModels) #if canImport(FoundationModels)
import FoundationModels import FoundationModels
#endif #endif
@ -9,11 +10,50 @@ import AppKit
import UIKit import UIKit
#endif #endif
#if os(macOS)
/// MARK: - Types /// 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 { final class AppDelegate: NSObject, NSApplicationDelegate {
weak var viewModel: EditorViewModel? { weak var viewModel: EditorViewModel? {
didSet { didSet {
@ -90,6 +130,7 @@ struct NeonVisionEditorApp: App {
@StateObject private var supportPurchaseManager = SupportPurchaseManager() @StateObject private var supportPurchaseManager = SupportPurchaseManager()
@StateObject private var appUpdateManager = AppUpdateManager() @StateObject private var appUpdateManager = AppUpdateManager()
@AppStorage("SettingsAppearance") private var appearance: String = "system" @AppStorage("SettingsAppearance") private var appearance: String = "system"
@AppStorage("SettingsAppLanguageCode") private var appLanguageCode: String = "system"
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
private let mainStartupBehavior: ContentView.StartupBehavior private let mainStartupBehavior: ContentView.StartupBehavior
private let startupSafeModeMessage: String? private let startupSafeModeMessage: String?
@ -108,6 +149,16 @@ struct NeonVisionEditorApp: App {
ReleaseRuntimePolicy.preferredColorScheme(for: appearance) ReleaseRuntimePolicy.preferredColorScheme(for: appearance)
} }
private var preferredLocale: Locale {
appLanguageCode == "system"
? .autoupdatingCurrent
: Locale(identifier: appLanguageCode)
}
private func applyRuntimeLanguageOverride() {
RuntimeLanguageOverride.apply(languageCode: appLanguageCode)
}
private func completeLaunchReliabilityTrackingIfNeeded() { private func completeLaunchReliabilityTrackingIfNeeded() {
guard !didMarkLaunchCompleted else { return } guard !didMarkLaunchCompleted else { return }
didMarkLaunchCompleted = true didMarkLaunchCompleted = true
@ -217,6 +268,7 @@ struct NeonVisionEditorApp: App {
"SettingsCompletionFromSyntax": false, "SettingsCompletionFromSyntax": false,
"SettingsReopenLastSession": true, "SettingsReopenLastSession": true,
"SettingsOpenWithBlankDocument": false, "SettingsOpenWithBlankDocument": false,
"SettingsAppLanguageCode": "system",
"SettingsDefaultNewFileLanguage": "plain", "SettingsDefaultNewFileLanguage": "plain",
"SettingsConfirmCloseDirtyTab": true, "SettingsConfirmCloseDirtyTab": true,
"SettingsConfirmClearEditor": true, "SettingsConfirmClearEditor": true,
@ -248,6 +300,9 @@ struct NeonVisionEditorApp: App {
self.startupSafeModeMessage = safeModeDecision.message self.startupSafeModeMessage = safeModeDecision.message
RuntimeReliabilityMonitor.shared.startMainThreadWatchdog() RuntimeReliabilityMonitor.shared.startMainThreadWatchdog()
EditorPerformanceMonitor.shared.markLaunchConfigured() EditorPerformanceMonitor.shared.markLaunchConfigured()
RuntimeLanguageOverride.apply(
languageCode: defaults.string(forKey: "SettingsAppLanguageCode") ?? "system"
)
} }
#if os(macOS) #if os(macOS)
@ -289,8 +344,11 @@ struct NeonVisionEditorApp: App {
.onAppear { applyGlobalAppearanceOverride() } .onAppear { applyGlobalAppearanceOverride() }
.onAppear { applyMacWindowTabbingPolicy() } .onAppear { applyMacWindowTabbingPolicy() }
.onChange(of: appearance) { _, _ in applyGlobalAppearanceOverride() } .onChange(of: appearance) { _, _ in applyGlobalAppearanceOverride() }
.onAppear { applyRuntimeLanguageOverride() }
.onChange(of: appLanguageCode) { _, _ in applyRuntimeLanguageOverride() }
.environment(\.showGrokError, $showGrokError) .environment(\.showGrokError, $showGrokError)
.environment(\.grokErrorMessage, $grokErrorMessage) .environment(\.grokErrorMessage, $grokErrorMessage)
.environment(\.locale, preferredLocale)
.tint(.blue) .tint(.blue)
.preferredColorScheme(preferredAppearance) .preferredColorScheme(preferredAppearance)
.onChange(of: scenePhase) { _, newPhase in .onChange(of: scenePhase) { _, newPhase in
@ -347,6 +405,9 @@ struct NeonVisionEditorApp: App {
.onAppear { applyGlobalAppearanceOverride() } .onAppear { applyGlobalAppearanceOverride() }
.onAppear { applyMacWindowTabbingPolicy() } .onAppear { applyMacWindowTabbingPolicy() }
.onChange(of: appearance) { _, _ in applyGlobalAppearanceOverride() } .onChange(of: appearance) { _, _ in applyGlobalAppearanceOverride() }
.onAppear { applyRuntimeLanguageOverride() }
.onChange(of: appLanguageCode) { _, _ in applyRuntimeLanguageOverride() }
.environment(\.locale, preferredLocale)
.tint(.blue) .tint(.blue)
.preferredColorScheme(preferredAppearance) .preferredColorScheme(preferredAppearance)
} }
@ -364,6 +425,9 @@ struct NeonVisionEditorApp: App {
.onAppear { applyGlobalAppearanceOverride() } .onAppear { applyGlobalAppearanceOverride() }
.onAppear { applyMacWindowTabbingPolicy() } .onAppear { applyMacWindowTabbingPolicy() }
.onChange(of: appearance) { _, _ in applyGlobalAppearanceOverride() } .onChange(of: appearance) { _, _ in applyGlobalAppearanceOverride() }
.onAppear { applyRuntimeLanguageOverride() }
.onChange(of: appLanguageCode) { _, _ in applyRuntimeLanguageOverride() }
.environment(\.locale, preferredLocale)
.tint(.blue) .tint(.blue)
.preferredColorScheme(preferredAppearance) .preferredColorScheme(preferredAppearance)
} }
@ -371,6 +435,9 @@ struct NeonVisionEditorApp: App {
Window("AI Activity Log", id: "ai-logs") { Window("AI Activity Log", id: "ai-logs") {
AIActivityLogView() AIActivityLogView()
.frame(minWidth: 720, minHeight: 420) .frame(minWidth: 720, minHeight: 420)
.onAppear { applyRuntimeLanguageOverride() }
.onChange(of: appLanguageCode) { _, _ in applyRuntimeLanguageOverride() }
.environment(\.locale, preferredLocale)
.preferredColorScheme(preferredAppearance) .preferredColorScheme(preferredAppearance)
.tint(.blue) .tint(.blue)
} }
@ -441,6 +508,9 @@ struct NeonVisionEditorApp: App {
.environmentObject(appUpdateManager) .environmentObject(appUpdateManager)
.environment(\.showGrokError, $showGrokError) .environment(\.showGrokError, $showGrokError)
.environment(\.grokErrorMessage, $grokErrorMessage) .environment(\.grokErrorMessage, $grokErrorMessage)
.environment(\.locale, preferredLocale)
.onAppear { applyRuntimeLanguageOverride() }
.onChange(of: appLanguageCode) { _, _ in applyRuntimeLanguageOverride() }
.tint(.blue) .tint(.blue)
.onAppear { applyIOSAppearanceOverride() } .onAppear { applyIOSAppearanceOverride() }
.onChange(of: scenePhase) { _, newPhase in .onChange(of: scenePhase) { _, newPhase in

View file

@ -632,6 +632,7 @@ class EditorViewModel {
private enum TabCommand: Sendable { private enum TabCommand: Sendable {
case updateContent(tabID: UUID, mutation: TabContentMutation) case updateContent(tabID: UUID, mutation: TabContentMutation)
case markSaved(tabID: UUID, fileURL: URL?, fingerprint: UInt64?, fileModificationDate: Date?) 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 setLanguage(tabID: UUID, language: String, lock: Bool)
case closeTab(tabID: UUID) case closeTab(tabID: UUID)
case addNewTab(name: String, language: String) case addNewTab(name: String, language: String)
@ -704,6 +705,25 @@ class EditorViewModel {
recordTabStateMutation(rebuildIndexes: true) recordTabStateMutation(rebuildIndexes: true)
return outcome 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): case let .setLanguage(tabID, language, lock):
guard let index = tabIndex(for: tabID) else { return TabCommandOutcome() } guard let index = tabIndex(for: tabID) else { return TabCommandOutcome() }
if tabs[index].language == language, tabs[index].languageLocked == lock { 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. // Returns whitespace-delimited word count for status display.
func wordCount(for text: String) -> Int { func wordCount(for text: String) -> Int {
text.split(whereSeparator: \.isWhitespace).count text.split(whereSeparator: \.isWhitespace).count

View file

@ -976,6 +976,370 @@ extension ContentView {
persistSessionIfReady() 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] { private nonisolated static func buildProjectTree(at root: URL, supportedOnly: Bool) -> [ProjectTreeNode] {
var isDir: ObjCBool = false var isDir: ObjCBool = false
guard FileManager.default.fileExists(atPath: root.path, isDirectory: &isDir), isDir.boolValue else { return [] } guard FileManager.default.fileExists(atPath: root.path, isDirectory: &isDir), isDir.boolValue else { return [] }
@ -1001,6 +1365,7 @@ extension ContentView {
#endif #endif
projectRootFolderURL = folderURL projectRootFolderURL = folderURL
projectTreeNodes = [] projectTreeNodes = []
projectTreeRevealURL = nil
quickSwitcherProjectFileURLs = [] quickSwitcherProjectFileURLs = []
projectFileIndexSnapshot = .empty projectFileIndexSnapshot = .empty
isProjectFileIndexing = false isProjectFileIndexing = false

View file

@ -267,17 +267,17 @@ extension ContentView {
return """ return """
\(previewLayoutCSS) \(previewLayoutCSS)
html { html {
-webkit-text-size-adjust: \(isPad ? "144%" : "118%"); -webkit-text-size-adjust: \(isPad ? "126%" : "108%");
} }
body { body {
font-size: \(isPad ? "1.24em" : "1.1em"); font-size: \(isPad ? "1.08em" : "0.98em");
} }
""" """
#else #else
return """ return """
\(previewLayoutCSS) \(previewLayoutCSS)
body { body {
font-size: 1.08em; font-size: 0.96em;
} }
""" """
#endif #endif

View file

@ -55,6 +55,63 @@ extension ContentView {
return NeonUIStyle.accentBlue 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 #endif
#if os(iOS) #if os(iOS)
@ -497,6 +554,103 @@ extension ContentView {
.accessibilityLabel("Markdown Preview") .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 @ViewBuilder
private var keyboardAccessoryControl: some View { private var keyboardAccessoryControl: some View {
Button(action: { Button(action: {
@ -767,6 +921,14 @@ extension ContentView {
} }
.disabled(currentLanguage != "markdown") .disabled(currentLanguage != "markdown")
if showMarkdownPreviewPane && currentLanguage == "markdown" {
Menu {
markdownPreviewExportToolbarMenuContent
} label: {
Label("Export PDF", systemImage: "square.and.arrow.down")
}
}
Button(action: { requestCloseAllTabsFromToolbar() }) { Button(action: { requestCloseAllTabsFromToolbar() }) {
Label("Close All Tabs", systemImage: "xmark.square") Label("Close All Tabs", systemImage: "xmark.square")
} }
@ -862,6 +1024,8 @@ extension ContentView {
private var iOSToolbarControls: some View { private var iOSToolbarControls: some View {
openFileControl openFileControl
undoControl undoControl
markdownPreviewExportControl
markdownPreviewStyleControl
if iPhonePromotedActionsCount >= 2 { newTabControl } if iPhonePromotedActionsCount >= 2 { newTabControl }
if iPhonePromotedActionsCount >= 3 { saveFileControl } if iPhonePromotedActionsCount >= 3 { saveFileControl }
if iPhonePromotedActionsCount >= 4 { findReplaceControl } if iPhonePromotedActionsCount >= 4 { findReplaceControl }
@ -903,6 +1067,8 @@ extension ContentView {
@ViewBuilder @ViewBuilder
private var iPadDistributedToolbarControls: some View { private var iPadDistributedToolbarControls: some View {
languagePickerControl languagePickerControl
markdownPreviewExportControl
markdownPreviewStyleControl
ForEach(iPadPromotedActions, id: \.self) { action in ForEach(iPadPromotedActions, id: \.self) { action in
iPadToolbarActionControl(action) iPadToolbarActionControl(action)
.frame(minWidth: 40, minHeight: 40) .frame(minWidth: 40, minHeight: 40)
@ -1140,6 +1306,14 @@ extension ContentView {
.help("Toggle Markdown Preview") .help("Toggle Markdown Preview")
if showMarkdownPreviewPane && currentLanguage == "markdown" { 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 { Menu {
Button("Default") { markdownPreviewTemplateRaw = "default" } Button("Default") { markdownPreviewTemplateRaw = "default" }
Button("Docs") { markdownPreviewTemplateRaw = "docs" } Button("Docs") { markdownPreviewTemplateRaw = "docs" }
@ -1157,10 +1331,10 @@ extension ContentView {
Button("Dense Compact") { markdownPreviewTemplateRaw = "dense-compact" } Button("Dense Compact") { markdownPreviewTemplateRaw = "dense-compact" }
Button("Developer Spec") { markdownPreviewTemplateRaw = "developer-spec" } Button("Developer Spec") { markdownPreviewTemplateRaw = "developer-spec" }
} label: { } label: {
Label("Preview Style", systemImage: "textformat.size") Label(NSLocalizedString("Preview Style", comment: "Markdown preview style menu label"), systemImage: "paintbrush")
.foregroundStyle(macToolbarSymbolColor) .foregroundStyle(macToolbarSymbolColor)
} }
.help("Markdown Preview Template") .help(NSLocalizedString("Markdown Preview Template", comment: "Toolbar help for markdown preview style menu"))
} }
} }
#endif #endif

View file

@ -128,6 +128,29 @@ struct ContentView: View {
var id: String { rawValue } 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 { struct DelimitedTableSnapshot: Sendable {
let header: [String] let header: [String]
let rows: [[String]] let rows: [[String]]
@ -277,7 +300,9 @@ struct ContentView: View {
@State var projectRootFolderURL: URL? = nil @State var projectRootFolderURL: URL? = nil
@State var projectTreeNodes: [ProjectTreeNode] = [] @State var projectTreeNodes: [ProjectTreeNode] = []
@State var projectTreeRefreshGeneration: Int = 0 @State var projectTreeRefreshGeneration: Int = 0
@State var projectTreeRevealURL: URL? = nil
@AppStorage("SettingsShowSupportedProjectFilesOnly") var showSupportedProjectFilesOnly: Bool = true @AppStorage("SettingsShowSupportedProjectFilesOnly") var showSupportedProjectFilesOnly: Bool = true
@AppStorage("SettingsShowInvisibleCharacters") var showInvisibleCharacters: Bool = false
@State var projectOverrideIndentWidth: Int? = nil @State var projectOverrideIndentWidth: Int? = nil
@State var projectOverrideLineWrapEnabled: Bool? = nil @State var projectOverrideLineWrapEnabled: Bool? = nil
@State var showProjectFolderPicker: Bool = false @State var showProjectFolderPicker: Bool = false
@ -296,6 +321,18 @@ struct ContentView: View {
@State var showIOSFileExporter: Bool = false @State var showIOSFileExporter: Bool = false
@State var showUnsupportedFileAlert: Bool = false @State var showUnsupportedFileAlert: Bool = false
@State var unsupportedFileName: String = "" @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 iosExportDocument: PlainTextDocument = PlainTextDocument(text: "")
@State var iosExportFilename: String = "Untitled.txt" @State var iosExportFilename: String = "Untitled.txt"
@State var iosExportTabID: UUID? = nil @State var iosExportTabID: UUID? = nil
@ -336,7 +373,7 @@ struct ContentView: View {
@State var droppedFileLoadProgress: Double = 0 @State var droppedFileLoadProgress: Double = 0
@State var droppedFileLoadLabel: String = "" @State var droppedFileLoadLabel: String = ""
@State var largeFileModeEnabled: Bool = false @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 projectSidebarResizeStartWidth: CGFloat? = nil
@State private var delimitedViewMode: DelimitedViewMode = .table @State private var delimitedViewMode: DelimitedViewMode = .table
@State private var delimitedTableSnapshot: DelimitedTableSnapshot? = nil @State private var delimitedTableSnapshot: DelimitedTableSnapshot? = nil
@ -416,8 +453,11 @@ struct ContentView: View {
PerformancePreset(rawValue: performancePresetRaw) ?? .balanced PerformancePreset(rawValue: performancePresetRaw) ?? .balanced
} }
private var minimumProjectSidebarWidth: CGFloat { 320 }
private var maximumProjectSidebarWidth: CGFloat { 520 }
private var clampedProjectSidebarWidth: CGFloat { private var clampedProjectSidebarWidth: CGFloat {
let clamped = min(max(projectSidebarWidth, 220), 520) let clamped = min(max(projectSidebarWidth, Double(minimumProjectSidebarWidth)), Double(maximumProjectSidebarWidth))
return CGFloat(clamped) return CGFloat(clamped)
} }
@ -2304,8 +2344,8 @@ struct ContentView: View {
viewModel.showSidebar = false viewModel.showSidebar = false
showProjectStructureSidebar = false showProjectStructureSidebar = false
#if os(iOS) #if os(iOS)
if UIDevice.current.userInterfaceIdiom == .pad && abs(projectSidebarWidth - 260) < 0.5 { if UIDevice.current.userInterfaceIdiom == .pad && projectSidebarWidth < Double(minimumProjectSidebarWidth) {
projectSidebarWidth = 292 projectSidebarWidth = Double(minimumProjectSidebarWidth)
} }
#endif #endif
didRunInitialWindowLayoutSetup = true didRunInitialWindowLayoutSetup = true
@ -2597,7 +2637,13 @@ struct ContentView: View {
onOpenFolder: { contentView.openProjectFolder() }, onOpenFolder: { contentView.openProjectFolder() },
onToggleSupportedFilesOnly: { contentView.showSupportedProjectFilesOnly = $0 }, onToggleSupportedFilesOnly: { contentView.showSupportedProjectFilesOnly = $0 },
onOpenProjectFile: { contentView.openProjectFile(url: $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: ""))) .navigationTitle(Text(NSLocalizedString("Project Structure", comment: "")))
.toolbar { .toolbar {
@ -2895,6 +2941,51 @@ struct ContentView: View {
contentView.unsupportedFileName 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) #if canImport(UIKit)
.fileImporter( .fileImporter(
isPresented: contentView.$showIOSFileImporter, isPresented: contentView.$showIOSFileImporter,
@ -4130,7 +4221,7 @@ struct ContentView: View {
case .trailing: case .trailing:
proposed = startWidth - delta proposed = startWidth - delta
} }
let clamped = min(max(proposed, 220), 520) let clamped = min(max(proposed, minimumProjectSidebarWidth), maximumProjectSidebarWidth)
projectSidebarWidth = Double(clamped) projectSidebarWidth = Double(clamped)
} }
.onEnded { _ in .onEnded { _ in
@ -4239,7 +4330,13 @@ struct ContentView: View {
onOpenFolder: { openProjectFolder() }, onOpenFolder: { openProjectFolder() },
onToggleSupportedFilesOnly: { showSupportedProjectFilesOnly = $0 }, onToggleSupportedFilesOnly: { showSupportedProjectFilesOnly = $0 },
onOpenProjectFile: { openProjectFile(url: $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 #endif
}(), }(),
showLineNumbers: showLineNumbers, showLineNumbers: showLineNumbers,
showInvisibleCharacters: false, showInvisibleCharacters: showInvisibleCharacters,
highlightCurrentLine: effectiveHighlightCurrentLine, highlightCurrentLine: effectiveHighlightCurrentLine,
highlightMatchingBrackets: effectiveBracketHighlight, highlightMatchingBrackets: effectiveBracketHighlight,
showScopeGuides: effectiveScopeGuides, showScopeGuides: effectiveScopeGuides,
@ -4830,11 +4927,6 @@ struct ContentView: View {
@ViewBuilder @ViewBuilder
private var markdownPreviewPane: some View { private var markdownPreviewPane: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
markdownPreviewHeader
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(editorSurfaceBackgroundStyle)
MarkdownPreviewWebView( MarkdownPreviewWebView(
html: markdownPreviewHTML( html: markdownPreviewHTML(
from: currentContent, from: currentContent,

View file

@ -878,7 +878,7 @@ final class AcceptingTextView: NSTextView {
private var vimObservers: [TextViewObserverToken] = [] private var vimObservers: [TextViewObserverToken] = []
private var activityObservers: [TextViewObserverToken] = [] private var activityObservers: [TextViewObserverToken] = []
private var didConfigureVimMode: Bool = false private var didConfigureVimMode: Bool = false
private var didApplyDeepInvisibleDisable: Bool = false private var lastAppliedInvisiblePreference: Bool?
private var defaultsObserver: TextViewObserverToken? private var defaultsObserver: TextViewObserverToken?
private let dropReadChunkSize = 64 * 1024 private let dropReadChunkSize = 64 * 1024
fileprivate var isApplyingDroppedContent: Bool = false fileprivate var isApplyingDroppedContent: Bool = false
@ -938,7 +938,7 @@ final class AcceptingTextView: NSTextView {
} }
override func draw(_ dirtyRect: NSRect) { 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() forceDisableInvisibleGlyphRendering()
super.draw(dirtyRect) super.draw(dirtyRect)
} }
@ -1250,9 +1250,8 @@ final class AcceptingTextView: NSTextView {
} }
let sanitized = sanitizedPlainText(s) let sanitized = sanitizedPlainText(s)
// Ensure invisibles off after insertion // Keep invisible/control marker rendering aligned with current preference.
self.layoutManager?.showsInvisibleCharacters = false forceDisableInvisibleGlyphRendering()
self.layoutManager?.showsControlCharacters = false
// Auto-indent by copying leading whitespace // Auto-indent by copying leading whitespace
if sanitized == "\n" && autoIndentEnabled { if sanitized == "\n" && autoIndentEnabled {
@ -1435,9 +1434,7 @@ final class AcceptingTextView: NSTextView {
textStorage?.endEditing() textStorage?.endEditing()
isApplyingPaste = false isApplyingPaste = false
// Ensure invisibles are off after paste forceDisableInvisibleGlyphRendering()
self.layoutManager?.showsInvisibleCharacters = false
self.layoutManager?.showsControlCharacters = false
NotificationCenter.default.post(name: .pastedText, object: sanitized) NotificationCenter.default.post(name: .pastedText, object: sanitized)
didChangeText() didChangeText()
@ -1451,9 +1448,7 @@ final class AcceptingTextView: NSTextView {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.isApplyingPaste = false self?.isApplyingPaste = false
// Ensure invisibles are off after async paste self?.forceDisableInvisibleGlyphRendering()
self?.layoutManager?.showsInvisibleCharacters = false
self?.layoutManager?.showsControlCharacters = false
} }
// Enforce caret after paste (multiple ticks beats late selection changes) // Enforce caret after paste (multiple ticks beats late selection changes)
@ -1532,42 +1527,25 @@ final class AcceptingTextView: NSTextView {
private func forceDisableInvisibleGlyphRendering(deep: Bool = false) { private func forceDisableInvisibleGlyphRendering(deep: Bool = false) {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
if defaults.bool(forKey: "NSShowAllInvisibles") || defaults.bool(forKey: "NSShowControlCharacters") { let shouldShow = defaults.bool(forKey: "SettingsShowInvisibleCharacters")
defaults.set(false, forKey: "NSShowAllInvisibles") if defaults.bool(forKey: "NSShowAllInvisibles") != shouldShow {
defaults.set(false, forKey: "NSShowControlCharacters") defaults.set(shouldShow, forKey: "NSShowAllInvisibles")
} }
layoutManager?.showsInvisibleCharacters = false if defaults.bool(forKey: "NSShowControlCharacters") != shouldShow {
layoutManager?.showsControlCharacters = false defaults.set(shouldShow, forKey: "NSShowControlCharacters")
}
layoutManager?.showsInvisibleCharacters = shouldShow
layoutManager?.showsControlCharacters = shouldShow
guard deep, !didApplyDeepInvisibleDisable else { return } guard deep else { return }
didApplyDeepInvisibleDisable = true if lastAppliedInvisiblePreference == shouldShow {
return
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)
}
} }
if #available(macOS 12.0, *) { lastAppliedInvisiblePreference = shouldShow
if let tlm = value(forKey: "textLayoutManager") as? NSObject { if let storage = textStorage {
for selectorName in selectors { layoutManager?.invalidateDisplay(forCharacterRange: NSRange(location: 0, length: storage.length))
let selector = NSSelectorFromString(selectorName)
if tlm.responds(to: selector) {
_ = tlm.perform(selector, with: NSNumber(value: false))
}
}
}
} }
needsDisplay = true
} }
private func configureActivityObservers() { private func configureActivityObservers() {
@ -1968,42 +1946,18 @@ struct CustomTextEditor: NSViewRepresentable {
} }
private func applyInvisibleCharacterPreference(_ textView: NSTextView) { 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 let defaults = UserDefaults.standard
defaults.set(false, forKey: "NSShowAllInvisibles") defaults.set(shouldShow, forKey: "NSShowAllInvisibles")
defaults.set(false, forKey: "NSShowControlCharacters") defaults.set(shouldShow, forKey: "NSShowControlCharacters")
defaults.set(false, forKey: "SettingsShowInvisibleCharacters") defaults.set(shouldShow, forKey: "SettingsShowInvisibleCharacters")
textView.layoutManager?.showsInvisibleCharacters = false textView.layoutManager?.showsInvisibleCharacters = shouldShow
textView.layoutManager?.showsControlCharacters = false textView.layoutManager?.showsControlCharacters = shouldShow
let value = NSNumber(value: false) if let storage = textView.textStorage {
let selectors = [ textView.layoutManager?.invalidateDisplay(forCharacterRange: NSRange(location: 0, length: storage.length))
"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)
}
}
}
} }
textView.needsDisplay = true
} }
private func sanitizedForExternalSet(_ input: String) -> String { private func sanitizedForExternalSet(_ input: String) -> String {
@ -4086,6 +4040,16 @@ struct CustomTextEditor: UIViewRepresentable {
return UIFont.monospacedSystemFont(ofSize: targetSize, weight: .regular) 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 { func makeUIView(context: Context) -> LineNumberedTextViewContainer {
let container = LineNumberedTextViewContainer() let container = LineNumberedTextViewContainer()
let textView = container.textView let textView = container.textView
@ -4123,6 +4087,7 @@ struct CustomTextEditor: UIViewRepresentable {
if #available(iOS 18.0, *) { if #available(iOS 18.0, *) {
textView.writingToolsBehavior = .none textView.writingToolsBehavior = .none
} }
applyInvisibleCharacterPreference(textView)
textView.backgroundColor = translucentBackgroundEnabled ? .clear : .systemBackground textView.backgroundColor = translucentBackgroundEnabled ? .clear : .systemBackground
textView.setBracketAccessoryVisible(showKeyboardAccessoryBar) textView.setBracketAccessoryVisible(showKeyboardAccessoryBar)
let shouldWrapText = isLineWrapEnabled && !isLargeFileMode let shouldWrapText = isLineWrapEnabled && !isLargeFileMode
@ -4261,6 +4226,7 @@ struct CustomTextEditor: UIViewRepresentable {
textView.writingToolsBehavior = .none textView.writingToolsBehavior = .none
} }
} }
applyInvisibleCharacterPreference(textView)
textView.typingAttributes[.foregroundColor] = baseColor textView.typingAttributes[.foregroundColor] = baseColor
if !showLineNumbers { if !showLineNumbers {
uiView.lineNumberView.isHidden = true uiView.lineNumberView.isHidden = true

View file

@ -43,6 +43,7 @@ struct NeonSettingsView: View {
@AppStorage("SettingsEditorFontSize") private var editorFontSize: Double = 14 @AppStorage("SettingsEditorFontSize") private var editorFontSize: Double = 14
@AppStorage("SettingsLineHeight") private var lineHeight: Double = 1.0 @AppStorage("SettingsLineHeight") private var lineHeight: Double = 1.0
@AppStorage("SettingsAppearance") private var appearance: String = "system" @AppStorage("SettingsAppearance") private var appearance: String = "system"
@AppStorage("SettingsAppLanguageCode") private var appLanguageCode: String = "system"
@AppStorage("SettingsToolbarSymbolsColorMac") private var toolbarSymbolsColorMacRaw: String = "blue" @AppStorage("SettingsToolbarSymbolsColorMac") private var toolbarSymbolsColorMacRaw: String = "blue"
#if os(iOS) #if os(iOS)
@AppStorage("EnableTranslucentWindow") private var translucentWindow: Bool = true @AppStorage("EnableTranslucentWindow") private var translucentWindow: Bool = true
@ -70,6 +71,7 @@ struct NeonSettingsView: View {
@AppStorage("SettingsShowScopeGuides") private var showScopeGuides: Bool = false @AppStorage("SettingsShowScopeGuides") private var showScopeGuides: Bool = false
@AppStorage("SettingsHighlightScopeBackground") private var highlightScopeBackground: Bool = false @AppStorage("SettingsHighlightScopeBackground") private var highlightScopeBackground: Bool = false
@AppStorage("SettingsLineWrapEnabled") private var lineWrapEnabled: Bool = false @AppStorage("SettingsLineWrapEnabled") private var lineWrapEnabled: Bool = false
@AppStorage("SettingsShowInvisibleCharacters") private var showInvisibleCharacters: Bool = false
@AppStorage("SettingsIndentStyle") private var indentStyle: String = "spaces" @AppStorage("SettingsIndentStyle") private var indentStyle: String = "spaces"
@AppStorage("SettingsIndentWidth") private var indentWidth: Int = 4 @AppStorage("SettingsIndentWidth") private var indentWidth: Int = 4
@AppStorage("SettingsAutoIndent") private var autoIndent: Bool = true @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", "csharp", "objective-c", "json", "xml", "yaml", "toml", "csv", "ini", "vim", "log", "ipynb",
"markdown", "tex", "bash", "zsh", "powershell", "standard", "plain" "markdown", "tex", "bash", "zsh", "powershell", "standard", "plain"
] ]
private let appLanguageOptions: [String] = [
"system",
"en",
"de",
"zh-Hans"
]
private var isCompactSettingsLayout: Bool { private var isCompactSettingsLayout: Bool {
#if os(iOS) #if os(iOS)
@ -392,6 +401,28 @@ struct NeonSettingsView: View {
String(format: NSLocalizedString(key, comment: ""), arguments: values) 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 { private var shouldShowSupportPurchaseControls: Bool {
#if os(iOS) #if os(iOS)
true true
@ -450,6 +481,7 @@ struct NeonSettingsView: View {
appUpdateManager.setAutoCheckEnabled(autoCheckForUpdates) appUpdateManager.setAutoCheckEnabled(autoCheckForUpdates)
appUpdateManager.setUpdateInterval(selectedUpdateInterval) appUpdateManager.setUpdateInterval(selectedUpdateInterval)
appUpdateManager.setAutoDownloadEnabled(autoDownloadUpdates) appUpdateManager.setAutoDownloadEnabled(autoDownloadUpdates)
applyAppLanguagePreferenceIfNeeded()
#if os(macOS) #if os(macOS)
applyAppearanceImmediately() applyAppearanceImmediately()
#endif #endif
@ -459,6 +491,9 @@ struct NeonSettingsView: View {
applyAppearanceImmediately() applyAppearanceImmediately()
#endif #endif
} }
.onChange(of: appLanguageCode) { _, _ in
applyAppLanguagePreferenceIfNeeded()
}
.onChange(of: showScopeGuides) { _, enabled in .onChange(of: showScopeGuides) { _, enabled in
if enabled && lineWrapEnabled { if enabled && lineWrapEnabled {
lineWrapEnabled = false lineWrapEnabled = false
@ -641,6 +676,19 @@ struct NeonSettingsView: View {
.pickerStyle(.segmented) .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 { if supportsTranslucency {
iOSToggleRow(LocalizedStringKey(localized("Translucent Window")), isOn: $translucentWindow) iOSToggleRow(LocalizedStringKey(localized("Translucent Window")), isOn: $translucentWindow)
} }
@ -669,9 +717,26 @@ struct NeonSettingsView: View {
Text(localized("Light")).tag("light") Text(localized("Light")).tag("light")
Text(localized("Dark")).tag("dark") 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) { HStack(alignment: .center, spacing: UI.space12) {
Text(localized("Toolbar Symbols")) Text(localized("Toolbar Symbols"))
.frame(width: isCompactSettingsLayout ? nil : standardLabelWidth, alignment: .leading) .frame(width: isCompactSettingsLayout ? nil : standardLabelWidth, alignment: .leading)
@ -1189,13 +1254,14 @@ struct NeonSettingsView: View {
Toggle("Show Scope Guides (Non-Swift)", isOn: $showScopeGuides) Toggle("Show Scope Guides (Non-Swift)", isOn: $showScopeGuides)
Toggle("Highlight Scoped Region", isOn: $highlightScopeBackground) Toggle("Highlight Scoped Region", isOn: $highlightScopeBackground)
Toggle("Line Wrap", isOn: $lineWrapEnabled) 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.") Text("When Line Wrap is enabled, scope guides/scoped region are turned off to avoid layout conflicts.")
.font(Typography.footnote) .font(Typography.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text("Scope guides are intended for non-Swift languages. Swift favors matching-token highlight.") Text("Scope guides are intended for non-Swift languages. Swift favors matching-token highlight.")
.font(Typography.footnote) .font(Typography.footnote)
.foregroundStyle(.secondary) .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) .font(Typography.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@ -1240,13 +1306,14 @@ struct NeonSettingsView: View {
Toggle("Show Scope Guides (Non-Swift)", isOn: $showScopeGuides) Toggle("Show Scope Guides (Non-Swift)", isOn: $showScopeGuides)
Toggle("Highlight Scoped Region", isOn: $highlightScopeBackground) Toggle("Highlight Scoped Region", isOn: $highlightScopeBackground)
Toggle("Line Wrap", isOn: $lineWrapEnabled) 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.") Text("When Line Wrap is enabled, scope guides/scoped region are turned off to avoid layout conflicts.")
.font(Typography.footnote) .font(Typography.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text("Scope guides are intended for non-Swift languages. Swift favors matching-token highlight.") Text("Scope guides are intended for non-Swift languages. Swift favors matching-token highlight.")
.font(Typography.footnote) .font(Typography.footnote)
.foregroundStyle(.secondary) .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) .font(Typography.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }

View file

@ -361,7 +361,14 @@ struct ProjectStructureSidebarView: View {
let onToggleSupportedFilesOnly: (Bool) -> Void let onToggleSupportedFilesOnly: (Bool) -> Void
let onOpenProjectFile: (URL) -> Void let onOpenProjectFile: (URL) -> Void
let onRefreshTree: () -> 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 expandedDirectories: Set<String> = []
@State private var hoveredNodeID: String? = nil
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
#if os(macOS) #if os(macOS)
@AppStorage("SettingsMacTranslucencyMode") private var macTranslucencyModeRaw: String = "balanced" @AppStorage("SettingsMacTranslucencyMode") private var macTranslucencyModeRaw: String = "balanced"
@ -372,59 +379,91 @@ struct ProjectStructureSidebarView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
if showsSidebarActionsRow { if showsSidebarActionsRow {
HStack { VStack(alignment: .leading, spacing: isCompactDensity ? 8 : 10) {
if showsInlineSidebarTitle { if showsInlineSidebarTitle {
Text("Project Structure") Text(NSLocalizedString("Project Structure", comment: "Project structure sidebar title"))
.font(.system(size: isCompactDensity ? 19 : 20, weight: .semibold)) .font(.system(size: isCompactDensity ? 19 : 20, weight: .semibold))
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(1)
} }
Spacer() HStack(spacing: isCompactDensity ? 10 : 12) {
Button(action: onOpenFolder) { Button(action: onOpenFolder) {
Image(systemName: "folder.badge.plus") Image(systemName: "folder")
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
.help("Open Folder…") .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) { Button(action: onOpenFile) {
Image(systemName: "doc.badge.plus") Image(systemName: "doc")
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
.help("Open File…") .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) { Menu {
Image(systemName: "arrow.clockwise") Button {
} onCreateProjectFile(nil)
.buttonStyle(.borderless) } label: {
.help("Refresh Folder Tree") Label(NSLocalizedString("New File", comment: "Project sidebar create file action"), systemImage: "doc.badge.plus")
}
Menu { Button {
Button { onCreateProjectFolder(nil)
onToggleSupportedFilesOnly(!showSupportedFilesOnly) } label: {
Label(NSLocalizedString("New Folder", comment: "Project sidebar create folder action"), systemImage: "folder.badge.plus")
}
} label: { } label: {
Label( Image(systemName: "plus")
"Show Supported Files Only",
systemImage: showSupportedFilesOnly ? "checkmark.circle.fill" : "circle"
)
} }
Divider() .buttonStyle(.borderless)
Picker("Density", selection: $sidebarDensityRaw) { .help(NSLocalizedString("Create in Project Root", comment: "Project sidebar create action"))
Text("Compact").tag(SidebarDensity.compact.rawValue) .accessibilityLabel(NSLocalizedString("Create project item", comment: "Project sidebar create accessibility label"))
Text("Comfortable").tag(SidebarDensity.comfortable.rawValue) .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) .buttonStyle(.borderless)
Divider() .help(NSLocalizedString("Refresh Folder Tree", comment: "Project sidebar refresh tree action"))
Button("Expand All") { .accessibilityLabel(NSLocalizedString("Refresh project tree", comment: "Project sidebar refresh accessibility label"))
expandAllDirectories() .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") { .buttonStyle(.borderless)
collapseAllDirectories() .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"))
} label: { .accessibilityHint(NSLocalizedString("Expands or collapses all folders in the project tree", comment: "Project sidebar expand/collapse accessibility hint"))
Image(systemName: "arrow.up.arrow.down.circle")
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(.horizontal, headerHorizontalPadding)
.padding(.top, headerTopPadding) .padding(.top, headerTopPadding)
@ -440,6 +479,18 @@ struct ProjectStructureSidebarView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(isCompactDensity ? 1 : 2) .lineLimit(isCompactDensity ? 1 : 2)
.textSelection(.enabled) .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(.horizontal, headerHorizontalPadding)
.padding(.top, showsSidebarActionsRow ? 0 : headerTopPadding) .padding(.top, showsSidebarActionsRow ? 0 : headerTopPadding)
.padding(.bottom, headerPathBottomPadding) .padding(.bottom, headerPathBottomPadding)
@ -447,12 +498,12 @@ struct ProjectStructureSidebarView: View {
List { List {
if rootFolderURL == nil { if rootFolderURL == nil {
Text("No folder selected") Text(NSLocalizedString("No folder selected", comment: "Project sidebar empty state without root folder"))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
} else if nodes.isEmpty { } else if nodes.isEmpty {
Text("Folder is empty") Text(NSLocalizedString("Folder is empty", comment: "Project sidebar empty state for selected folder"))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
@ -465,11 +516,34 @@ struct ProjectStructureSidebarView: View {
.listStyle(platformListStyle) .listStyle(platformListStyle)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.clear) .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) .padding(sidebarOuterPadding)
.background(sidebarContainerShape.fill(sidebarSurfaceFill)) .background(sidebarContainerShape.fill(sidebarSurfaceFill))
.overlay(sidebarContainerBorderOverlay) .overlay(sidebarContainerBorderOverlay)
.clipShape(sidebarContainerShape) .clipShape(sidebarContainerShape)
.onAppear {
revealTargetIfNeeded()
}
.onChange(of: revealPath) { _, _ in
revealTargetIfNeeded()
}
.onChange(of: nodes.count) { _, _ in
revealTargetIfNeeded()
}
#if os(macOS) #if os(macOS)
.overlay(alignment: boundaryEdge == .leading ? .leading : .trailing) { .overlay(alignment: boundaryEdge == .leading ? .leading : .trailing) {
if boundaryEdge != nil { if boundaryEdge != nil {
@ -604,6 +678,7 @@ struct ProjectStructureSidebarView: View {
private func projectNodeView(_ node: ProjectTreeNode, level: Int) -> AnyView { private func projectNodeView(_ node: ProjectTreeNode, level: Int) -> AnyView {
if node.isDirectory { if node.isDirectory {
let isHovered = hoveredNodeID == node.id
return AnyView( return AnyView(
DisclosureGroup(isExpanded: Binding( DisclosureGroup(isExpanded: Binding(
get: { expandedDirectories.contains(node.id) }, get: { expandedDirectories.contains(node.id) },
@ -631,16 +706,65 @@ struct ProjectStructureSidebarView: View {
.padding(.trailing, rowHorizontalPadding) .padding(.trailing, rowHorizontalPadding)
.padding(.leading, directoryRowContentLeadingPadding) .padding(.leading, directoryRowContentLeadingPadding)
.frame(maxWidth: .infinity, alignment: .leading) .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)) .padding(.leading, directoryRowLeadingInset(for: level))
.listRowInsets(rowInsets) .listRowInsets(rowInsets)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowSeparator(.hidden) .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 { } else {
let style = fileIconStyle(for: node.url) let style = fileIconStyle(for: node.url)
let isSelected = selectedFileURL?.standardizedFileURL == node.url.standardizedFileURL let isSelected = selectedFileURL?.standardizedFileURL == node.url.standardizedFileURL
let isHovered = hoveredNodeID == node.id
return AnyView( return AnyView(
Button { Button {
onOpenProjectFile(node.url) onOpenProjectFile(node.url)
@ -663,13 +787,60 @@ struct ProjectStructureSidebarView: View {
.padding(.vertical, rowVerticalPadding) .padding(.vertical, rowVerticalPadding)
.padding(.horizontal, rowHorizontalPadding) .padding(.horizontal, rowHorizontalPadding)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(rowChrome(isSelected: isSelected)) .background(rowChrome(isSelected: isSelected, isHovered: isHovered))
.contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.leading, CGFloat(level) * levelIndent) .padding(.leading, CGFloat(level) * levelIndent)
.listRowInsets(rowInsets) .listRowInsets(rowInsets)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowSeparator(.hidden) .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 isCompactDensity: Bool { sidebarDensity == .compact }
private var levelIndent: CGFloat { private var levelIndent: CGFloat {
isCompactDensity ? 8 : 11 isCompactDensity ? 10 : 13
} }
private var rowVerticalPadding: CGFloat { private var rowVerticalPadding: CGFloat {
isCompactDensity ? 6 : 8 isCompactDensity ? 7 : 9
} }
private var rowHorizontalPadding: CGFloat { private var rowHorizontalPadding: CGFloat {
isCompactDensity ? 10 : 12 isCompactDensity ? 10 : 13
} }
private var directoryRowContentSpacing: CGFloat { private var directoryRowContentSpacing: CGFloat {
@ -702,9 +873,9 @@ struct ProjectStructureSidebarView: View {
private var directoryRowContentLeadingPadding: CGFloat { private var directoryRowContentLeadingPadding: CGFloat {
#if os(macOS) #if os(macOS)
isCompactDensity ? 0 : 1 isCompactDensity ? 1 : 2
#else #else
isCompactDensity ? 3 : 4 isCompactDensity ? 4 : 5
#endif #endif
} }
@ -725,7 +896,7 @@ struct ProjectStructureSidebarView: View {
} }
private var rowInsets: EdgeInsets { 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 { private var showsInlineSidebarTitle: Bool {
@ -743,9 +914,9 @@ struct ProjectStructureSidebarView: View {
private func directoryRowLeadingInset(for level: Int) -> CGFloat { private func directoryRowLeadingInset(for level: Int) -> CGFloat {
let baseInset: CGFloat let baseInset: CGFloat
#if os(macOS) #if os(macOS)
baseInset = level == 0 ? (isCompactDensity ? 6 : 8) : 0 baseInset = level == 0 ? (isCompactDensity ? 12 : 14) : 0
#else #else
baseInset = level == 0 ? (isCompactDensity ? 4 : 6) : 0 baseInset = level == 0 ? (isCompactDensity ? 8 : 10) : 0
#endif #endif
return baseInset + CGFloat(level) * levelIndent return baseInset + CGFloat(level) * levelIndent
} }
@ -758,20 +929,66 @@ struct ProjectStructureSidebarView: View {
colorScheme == .dark ? .white : .primary 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) 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 { private var selectedRowFill: Color {
if colorScheme == .dark { 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 { 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 { private func fileIconStyle(for url: URL) -> FileIconStyle {

View file

@ -206,7 +206,6 @@
"Copy Markdown preview HTML" = "HTML der Markdown-Vorschau kopieren"; "Copy Markdown preview HTML" = "HTML der Markdown-Vorschau kopieren";
"Copy Markdown" = "Markdown kopieren"; "Copy Markdown" = "Markdown kopieren";
"Copy Markdown source" = "Markdown-Quelltext kopieren"; "Copy Markdown source" = "Markdown-Quelltext kopieren";
"More" = "Mehr";
"More Markdown preview actions" = "Weitere Aktionen für die Markdown-Vorschau"; "More Markdown preview actions" = "Weitere Aktionen für die Markdown-Vorschau";
"How To Connect" = "So verbindest du dich"; "How To Connect" = "So verbindest du dich";
"Copied the broker attach code." = "Der Broker-Attach-Code wurde kopiert."; "Copied the broker attach code." = "Der Broker-Attach-Code wurde kopiert.";
@ -386,12 +385,7 @@
"Insert Template" = "Vorlage einfügen"; "Insert Template" = "Vorlage einfügen";
"Enable Wrap / Disable Wrap" = "Zeilenumbruch aktivieren/deaktivieren"; "Enable Wrap / Disable Wrap" = "Zeilenumbruch aktivieren/deaktivieren";
"Settings" = "Einstellungen"; "Settings" = "Einstellungen";
"Markdown Preview" = "Markdown-Vorschau";
"Toggle Markdown Preview" = "Markdown-Vorschau ein-/ausblenden"; "Toggle Markdown Preview" = "Markdown-Vorschau ein-/ausblenden";
"Default" = "Standard";
"Docs" = "Doks";
"Article" = "Artikel";
"Compact" = "Kompakt";
"Preview Style" = "Vorschau-Stil"; "Preview Style" = "Vorschau-Stil";
"Markdown Preview Template" = "Markdown-Vorlage für Vorschau"; "Markdown Preview Template" = "Markdown-Vorlage für Vorschau";
"Open" = "Öffnen"; "Open" = "Öffnen";
@ -407,7 +401,6 @@
"Clear" = "Leeren"; "Clear" = "Leeren";
"Template" = "Vorlage"; "Template" = "Vorlage";
"Toggle Sidebar (Cmd+Opt+S)" = "Seitenleiste ein-/ausblenden (Cmd+Opt+S)"; "Toggle Sidebar (Cmd+Opt+S)" = "Seitenleiste ein-/ausblenden (Cmd+Opt+S)";
"Sidebar" = "Seitenleiste";
"Project" = "Projekt"; "Project" = "Projekt";
"Brackets" = "Klammern"; "Brackets" = "Klammern";
"Code Completion" = "Code-Vervollständigung"; "Code Completion" = "Code-Vervollständigung";
@ -430,3 +423,45 @@
"Show Supported Files Only" = "Nur unterstützte Dateien anzeigen"; "Show Supported Files Only" = "Nur unterstützte Dateien anzeigen";
"Enable Vim Mode" = "Vim-Modus aktivieren"; "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."; "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 preview HTML" = "Copy Markdown preview HTML";
"Copy Markdown" = "Copy Markdown"; "Copy Markdown" = "Copy Markdown";
"Copy Markdown source" = "Copy Markdown source"; "Copy Markdown source" = "Copy Markdown source";
"More" = "More";
"More Markdown preview actions" = "More Markdown preview actions"; "More Markdown preview actions" = "More Markdown preview actions";
"How To Connect" = "How To Connect"; "How To Connect" = "How To Connect";
"Copied the broker attach code." = "Copied the broker attach code."; "Copied the broker attach code." = "Copied the broker attach code.";
@ -331,3 +330,50 @@
"Show Supported Files Only" = "Show Supported Files Only"; "Show Supported Files Only" = "Show Supported Files Only";
"Enable Vim Mode" = "Enable Vim Mode"; "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."; "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 预览导出选项";