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