mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 13:27:16 +00:00
feat: prepare v0.5.6 milestone release
This commit is contained in:
parent
dd0d8856c6
commit
41966cd06c
22 changed files with 3093 additions and 640 deletions
29
CHANGELOG.md
29
CHANGELOG.md
|
|
@ -4,6 +4,35 @@ All notable changes to **Neon Vision Editor** are documented in this file.
|
|||
|
||||
The format follows *Keep a Changelog*. Versions use semantic versioning with prerelease tags.
|
||||
|
||||
## [v0.5.6] - 2026-03-17
|
||||
|
||||
### Hero Screenshot
|
||||
- 
|
||||
|
||||
### Why Upgrade
|
||||
- Safe Mode now recovers from repeated failed launches without getting stuck on every normal restart.
|
||||
- Large project folders now get a background file index that feeds `Quick Open` and `Find in Files` instead of relying only on live folder scans.
|
||||
- Theme formatting and Settings polish now apply immediately, with better localization and an iPad hardware-keyboard Vim MVP.
|
||||
|
||||
### Highlights
|
||||
- Added Safe Mode startup recovery with repeated-failure detection, blank-document launch fallback, a dedicated startup explanation, and a `Normal Next Launch` recovery action.
|
||||
- Added a background project file index for larger folders and wired it into `Quick Open`, `Find in Files`, and project refresh flows.
|
||||
- Added an iPad hardware-keyboard Vim MVP with core normal-mode navigation/editing commands and shared mode-state reporting.
|
||||
- Added theme formatting controls for bold keywords, italic comments, underlined links, and bold Markdown headings across active themes.
|
||||
|
||||
### Fixes
|
||||
- Fixed Safe Mode so a successful launch clears recovery state and normal restarts no longer re-enter Safe Mode unnecessarily.
|
||||
- Fixed theme-formatting updates so editor styling refreshes immediately without requiring a theme switch.
|
||||
- Fixed the editor font-size regression introduced by theme-formatting changes by restoring the base font before applying emphasis overrides.
|
||||
- Fixed duplicated Settings tab headings, icon/title alignment, and formatting-card placement to reduce scrolling and keep the Designs tab denser.
|
||||
- Fixed German Settings localization gaps and converted previously hard-coded diagnostics strings to localizable text.
|
||||
|
||||
### Breaking changes
|
||||
- None.
|
||||
|
||||
### Migration
|
||||
- None.
|
||||
|
||||
## [v0.5.5] - 2026-03-16
|
||||
|
||||
### Highlights
|
||||
|
|
|
|||
|
|
@ -361,7 +361,7 @@
|
|||
CODE_SIGNING_ALLOWED = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 530;
|
||||
CURRENT_PROJECT_VERSION = 531;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = CS727NF72U;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
|
|
@ -444,7 +444,7 @@
|
|||
CODE_SIGNING_ALLOWED = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 530;
|
||||
CURRENT_PROJECT_VERSION = 531;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = CS727NF72U;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
|
|
|
|||
|
|
@ -90,6 +90,10 @@ struct NeonVisionEditorApp: App {
|
|||
@StateObject private var supportPurchaseManager = SupportPurchaseManager()
|
||||
@StateObject private var appUpdateManager = AppUpdateManager()
|
||||
@AppStorage("SettingsAppearance") private var appearance: String = "system"
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
private let mainStartupBehavior: ContentView.StartupBehavior
|
||||
private let startupSafeModeMessage: String?
|
||||
@State private var didMarkLaunchCompleted: Bool = false
|
||||
#if os(macOS)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@State private var useAppleIntelligence: Bool = true
|
||||
|
|
@ -104,6 +108,12 @@ struct NeonVisionEditorApp: App {
|
|||
ReleaseRuntimePolicy.preferredColorScheme(for: appearance)
|
||||
}
|
||||
|
||||
private func completeLaunchReliabilityTrackingIfNeeded() {
|
||||
guard !didMarkLaunchCompleted else { return }
|
||||
didMarkLaunchCompleted = true
|
||||
RuntimeReliabilityMonitor.shared.markLaunchCompleted()
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private var appKitAppearance: NSAppearance? {
|
||||
switch appearance {
|
||||
|
|
@ -228,6 +238,9 @@ struct NeonVisionEditorApp: App {
|
|||
defaults.set(true, forKey: whitespaceMigrationKey)
|
||||
}
|
||||
RuntimeReliabilityMonitor.shared.markLaunch()
|
||||
let safeModeDecision = RuntimeReliabilityMonitor.shared.consumeSafeModeLaunchDecision()
|
||||
self.mainStartupBehavior = safeModeDecision.isEnabled ? .safeMode : .standard
|
||||
self.startupSafeModeMessage = safeModeDecision.message
|
||||
RuntimeReliabilityMonitor.shared.startMainThreadWatchdog()
|
||||
EditorPerformanceMonitor.shared.markLaunchConfigured()
|
||||
}
|
||||
|
|
@ -257,7 +270,10 @@ struct NeonVisionEditorApp: App {
|
|||
var body: some Scene {
|
||||
#if os(macOS)
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
ContentView(
|
||||
startupBehavior: mainStartupBehavior,
|
||||
safeModeMessage: startupSafeModeMessage
|
||||
)
|
||||
.environment(viewModel)
|
||||
.environmentObject(supportPurchaseManager)
|
||||
.environmentObject(appUpdateManager)
|
||||
|
|
@ -272,8 +288,14 @@ struct NeonVisionEditorApp: App {
|
|||
.environment(\.grokErrorMessage, $grokErrorMessage)
|
||||
.tint(.blue)
|
||||
.preferredColorScheme(preferredAppearance)
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
guard newPhase == .active else { return }
|
||||
completeLaunchReliabilityTrackingIfNeeded()
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 400)
|
||||
.task {
|
||||
completeLaunchReliabilityTrackingIfNeeded()
|
||||
guard mainStartupBehavior != .safeMode else { return }
|
||||
if ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution {
|
||||
appUpdateManager.startAutomaticChecks()
|
||||
}
|
||||
|
|
@ -404,7 +426,10 @@ struct NeonVisionEditorApp: App {
|
|||
}
|
||||
#else
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
ContentView(
|
||||
startupBehavior: mainStartupBehavior,
|
||||
safeModeMessage: startupSafeModeMessage
|
||||
)
|
||||
.environment(viewModel)
|
||||
.environmentObject(supportPurchaseManager)
|
||||
.environmentObject(appUpdateManager)
|
||||
|
|
@ -412,11 +437,18 @@ struct NeonVisionEditorApp: App {
|
|||
.environment(\.grokErrorMessage, $grokErrorMessage)
|
||||
.tint(.blue)
|
||||
.onAppear { applyIOSAppearanceOverride() }
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
guard newPhase == .active else { return }
|
||||
completeLaunchReliabilityTrackingIfNeeded()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willTerminateNotification)) { _ in
|
||||
RuntimeReliabilityMonitor.shared.markGracefulTermination()
|
||||
}
|
||||
.onChange(of: appearance) { _, _ in applyIOSAppearanceOverride() }
|
||||
.preferredColorScheme(preferredAppearance)
|
||||
.task {
|
||||
completeLaunchReliabilityTrackingIfNeeded()
|
||||
}
|
||||
}
|
||||
.commands {
|
||||
CommandGroup(replacing: .undoRedo) {
|
||||
|
|
|
|||
56
Neon Vision Editor/Core/ProjectFileIndex.swift
Normal file
56
Neon Vision Editor/Core/ProjectFileIndex.swift
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import Foundation
|
||||
|
||||
struct ProjectFileIndex {
|
||||
static func buildFileURLs(
|
||||
at root: URL,
|
||||
supportedOnly: Bool,
|
||||
isSupportedFile: @escaping @Sendable (URL) -> Bool
|
||||
) async -> [URL] {
|
||||
await Task.detached(priority: .utility) {
|
||||
let resourceKeys: [URLResourceKey] = [
|
||||
.isRegularFileKey,
|
||||
.isDirectoryKey,
|
||||
.isHiddenKey,
|
||||
.nameKey
|
||||
]
|
||||
let options: FileManager.DirectoryEnumerationOptions = [
|
||||
.skipsHiddenFiles,
|
||||
.skipsPackageDescendants
|
||||
]
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
at: root,
|
||||
includingPropertiesForKeys: resourceKeys,
|
||||
options: options
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
var results: [URL] = []
|
||||
results.reserveCapacity(512)
|
||||
|
||||
while let fileURL = enumerator.nextObject() as? URL {
|
||||
if Task.isCancelled {
|
||||
return []
|
||||
}
|
||||
guard let values = try? fileURL.resourceValues(forKeys: Set(resourceKeys)) else {
|
||||
continue
|
||||
}
|
||||
if values.isHidden == true {
|
||||
if values.isDirectory == true {
|
||||
enumerator.skipDescendants()
|
||||
}
|
||||
continue
|
||||
}
|
||||
guard values.isRegularFile == true else { continue }
|
||||
if supportedOnly && !isSupportedFile(fileURL) {
|
||||
continue
|
||||
}
|
||||
results.append(fileURL)
|
||||
}
|
||||
|
||||
return results.sorted {
|
||||
$0.path.localizedCaseInsensitiveCompare($1.path) == .orderedAscending
|
||||
}
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ struct RecentFilesStore {
|
|||
|
||||
private static let recentPathsKey = "RecentFilesPathsV1"
|
||||
private static let pinnedPathsKey = "PinnedRecentFilesPathsV1"
|
||||
private static let bookmarkMapKey = "RecentFilesBookmarksV1"
|
||||
private static let maximumItemCount = 30
|
||||
|
||||
static func items(limit: Int = maximumItemCount) -> [Item] {
|
||||
|
|
@ -19,9 +20,10 @@ struct RecentFilesStore {
|
|||
let recentPaths = sanitizedPaths(from: defaults.stringArray(forKey: recentPathsKey) ?? [])
|
||||
let pinnedPaths = sanitizedPaths(from: defaults.stringArray(forKey: pinnedPathsKey) ?? [])
|
||||
let pinnedSet = Set(pinnedPaths)
|
||||
let bookmarkMap = loadBookmarkMap(from: defaults)
|
||||
|
||||
let orderedPaths = pinnedPaths + recentPaths.filter { !pinnedSet.contains($0) }
|
||||
let urls = orderedPaths.prefix(limit).map { URL(fileURLWithPath: $0) }
|
||||
let urls = orderedPaths.prefix(limit).map { resolveURL(forPath: $0, bookmarkMap: bookmarkMap) }
|
||||
return urls.map { Item(url: $0, isPinned: pinnedSet.contains($0.standardizedFileURL.path)) }
|
||||
}
|
||||
|
||||
|
|
@ -40,6 +42,12 @@ struct RecentFilesStore {
|
|||
|
||||
defaults.set(trimmedRecent, forKey: recentPathsKey)
|
||||
defaults.set(pinnedPaths, forKey: pinnedPathsKey)
|
||||
var bookmarkMap = loadBookmarkMap(from: defaults)
|
||||
if let bookmark = makeSecurityScopedBookmark(for: url) {
|
||||
bookmarkMap[standardizedPath] = bookmark
|
||||
}
|
||||
pruneBookmarks(&bookmarkMap, keeping: Set(trimmedRecent).union(pinnedPaths))
|
||||
saveBookmarkMap(bookmarkMap, to: defaults)
|
||||
postDidChange()
|
||||
}
|
||||
|
||||
|
|
@ -66,12 +74,19 @@ struct RecentFilesStore {
|
|||
|
||||
defaults.set(recentPaths, forKey: recentPathsKey)
|
||||
defaults.set(pinnedPaths, forKey: pinnedPathsKey)
|
||||
var bookmarkMap = loadBookmarkMap(from: defaults)
|
||||
pruneBookmarks(&bookmarkMap, keeping: Set(recentPaths).union(pinnedPaths))
|
||||
saveBookmarkMap(bookmarkMap, to: defaults)
|
||||
postDidChange()
|
||||
}
|
||||
|
||||
static func clearUnpinned() {
|
||||
let defaults = UserDefaults.standard
|
||||
let pinnedPaths = sanitizedPaths(from: defaults.stringArray(forKey: pinnedPathsKey) ?? [])
|
||||
defaults.removeObject(forKey: recentPathsKey)
|
||||
var bookmarkMap = loadBookmarkMap(from: defaults)
|
||||
pruneBookmarks(&bookmarkMap, keeping: Set(pinnedPaths))
|
||||
saveBookmarkMap(bookmarkMap, to: defaults)
|
||||
postDidChange()
|
||||
}
|
||||
|
||||
|
|
@ -93,4 +108,76 @@ struct RecentFilesStore {
|
|||
NotificationCenter.default.post(name: .recentFilesDidChange, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadBookmarkMap(from defaults: UserDefaults) -> [String: Data] {
|
||||
guard let raw = defaults.dictionary(forKey: bookmarkMapKey) else { return [:] }
|
||||
var output: [String: Data] = [:]
|
||||
output.reserveCapacity(raw.count)
|
||||
for (path, value) in raw {
|
||||
guard let data = value as? Data else { continue }
|
||||
output[path] = data
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private static func saveBookmarkMap(_ map: [String: Data], to defaults: UserDefaults) {
|
||||
defaults.set(map, forKey: bookmarkMapKey)
|
||||
}
|
||||
|
||||
private static func pruneBookmarks(_ map: inout [String: Data], keeping paths: Set<String>) {
|
||||
map = map.filter { paths.contains($0.key) }
|
||||
}
|
||||
|
||||
private static func resolveURL(forPath path: String, bookmarkMap: [String: Data]) -> URL {
|
||||
guard let bookmarkData = bookmarkMap[path] else {
|
||||
return URL(fileURLWithPath: path)
|
||||
}
|
||||
var isStale = false
|
||||
guard
|
||||
let resolved = try? URL(
|
||||
resolvingBookmarkData: bookmarkData,
|
||||
options: bookmarkResolutionOptions,
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &isStale
|
||||
)
|
||||
else {
|
||||
return URL(fileURLWithPath: path)
|
||||
}
|
||||
return resolved.standardizedFileURL
|
||||
}
|
||||
|
||||
private static func makeSecurityScopedBookmark(for url: URL) -> Data? {
|
||||
let didAccess: Bool
|
||||
#if os(macOS)
|
||||
didAccess = url.startAccessingSecurityScopedResource()
|
||||
#else
|
||||
didAccess = false
|
||||
#endif
|
||||
defer {
|
||||
if didAccess {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
return try? url.bookmarkData(
|
||||
options: bookmarkCreationOptions,
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil
|
||||
)
|
||||
}
|
||||
|
||||
private static var bookmarkResolutionOptions: URL.BookmarkResolutionOptions {
|
||||
#if os(macOS)
|
||||
return [.withSecurityScope]
|
||||
#else
|
||||
return []
|
||||
#endif
|
||||
}
|
||||
|
||||
private static var bookmarkCreationOptions: URL.BookmarkCreationOptions {
|
||||
#if os(macOS)
|
||||
return [.withSecurityScope]
|
||||
#else
|
||||
return []
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import SwiftUI
|
|||
/// MARK: - Types
|
||||
|
||||
enum ReleaseRuntimePolicy {
|
||||
static let safeModeFailureThreshold = 2
|
||||
|
||||
static var isUpdaterEnabledForCurrentDistribution: Bool {
|
||||
#if os(macOS)
|
||||
return !isMacAppStoreDistribution
|
||||
|
|
@ -80,4 +82,27 @@ enum ReleaseRuntimePolicy {
|
|||
) -> Bool {
|
||||
canUseInAppPurchases && !isPurchasing && !isLoadingProducts
|
||||
}
|
||||
|
||||
static func shouldEnterSafeMode(
|
||||
consecutiveFailedLaunches: Int,
|
||||
requestedManually: Bool
|
||||
) -> Bool {
|
||||
requestedManually || consecutiveFailedLaunches >= safeModeFailureThreshold
|
||||
}
|
||||
|
||||
static func safeModeStartupMessage(
|
||||
consecutiveFailedLaunches: Int,
|
||||
requestedManually: Bool
|
||||
) -> String? {
|
||||
guard shouldEnterSafeMode(
|
||||
consecutiveFailedLaunches: consecutiveFailedLaunches,
|
||||
requestedManually: requestedManually
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
if requestedManually {
|
||||
return "Safe Mode is active for this launch. Session restore and startup diagnostics are paused."
|
||||
}
|
||||
return "Safe Mode is active because the last \(consecutiveFailedLaunches) launch attempts did not finish cleanly. Session restore and startup diagnostics are paused."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,21 @@ import OSLog
|
|||
/// MARK: - Types
|
||||
|
||||
final class RuntimeReliabilityMonitor {
|
||||
struct SafeModeLaunchDecision {
|
||||
let isEnabled: Bool
|
||||
let message: String?
|
||||
let consecutiveFailedLaunches: Int
|
||||
let requestedManually: Bool
|
||||
}
|
||||
|
||||
static let shared = RuntimeReliabilityMonitor()
|
||||
|
||||
private let logger = Logger(subsystem: "h3p.Neon-Vision-Editor", category: "Reliability")
|
||||
private let defaults = UserDefaults.standard
|
||||
private let activeRunKey = "Reliability.ActiveRunMarkerV1"
|
||||
private let crashBucketPrefix = "Reliability.CrashBucketV1."
|
||||
private let consecutiveFailedLaunchesKey = "Reliability.ConsecutiveFailedLaunchesV1"
|
||||
private let safeModeNextLaunchKey = "Reliability.SafeModeNextLaunchV1"
|
||||
private var watchdogTimer: DispatchSourceTimer?
|
||||
private var lastMainThreadPingUptime = ProcessInfo.processInfo.systemUptime
|
||||
|
||||
|
|
@ -23,6 +32,7 @@ final class RuntimeReliabilityMonitor {
|
|||
let key = crashBucketPrefix + currentBucketID()
|
||||
let current = defaults.integer(forKey: key)
|
||||
defaults.set(current + 1, forKey: key)
|
||||
defaults.set(defaults.integer(forKey: consecutiveFailedLaunchesKey) + 1, forKey: consecutiveFailedLaunchesKey)
|
||||
#if DEBUG
|
||||
logger.warning("reliability.previous_run_unfinished bucket=\(self.currentBucketID(), privacy: .public) count=\(current + 1, privacy: .public)")
|
||||
#endif
|
||||
|
|
@ -32,6 +42,12 @@ final class RuntimeReliabilityMonitor {
|
|||
|
||||
func markGracefulTermination() {
|
||||
defaults.set(false, forKey: activeRunKey)
|
||||
defaults.set(0, forKey: consecutiveFailedLaunchesKey)
|
||||
}
|
||||
|
||||
func markLaunchCompleted() {
|
||||
defaults.set(false, forKey: activeRunKey)
|
||||
defaults.set(0, forKey: consecutiveFailedLaunchesKey)
|
||||
}
|
||||
|
||||
func startMainThreadWatchdog() {
|
||||
|
|
@ -55,6 +71,33 @@ final class RuntimeReliabilityMonitor {
|
|||
#endif
|
||||
}
|
||||
|
||||
func requestSafeModeOnNextLaunch() {
|
||||
defaults.set(true, forKey: safeModeNextLaunchKey)
|
||||
}
|
||||
|
||||
func clearSafeModeRecoveryState() {
|
||||
defaults.set(0, forKey: consecutiveFailedLaunchesKey)
|
||||
defaults.set(false, forKey: safeModeNextLaunchKey)
|
||||
}
|
||||
|
||||
func consumeSafeModeLaunchDecision() -> SafeModeLaunchDecision {
|
||||
let requestedManually = defaults.bool(forKey: safeModeNextLaunchKey)
|
||||
if requestedManually {
|
||||
defaults.set(false, forKey: safeModeNextLaunchKey)
|
||||
}
|
||||
let consecutiveFailedLaunches = defaults.integer(forKey: consecutiveFailedLaunchesKey)
|
||||
let message = ReleaseRuntimePolicy.safeModeStartupMessage(
|
||||
consecutiveFailedLaunches: consecutiveFailedLaunches,
|
||||
requestedManually: requestedManually
|
||||
)
|
||||
return SafeModeLaunchDecision(
|
||||
isEnabled: message != nil,
|
||||
message: message,
|
||||
consecutiveFailedLaunches: consecutiveFailedLaunches,
|
||||
requestedManually: requestedManually
|
||||
)
|
||||
}
|
||||
|
||||
private func currentBucketID() -> String {
|
||||
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown"
|
||||
#if os(macOS)
|
||||
|
|
|
|||
|
|
@ -82,30 +82,308 @@ enum SyntaxPatternProfile {
|
|||
case jsonFast
|
||||
}
|
||||
|
||||
struct SyntaxEmphasisPatterns {
|
||||
let keyword: [String]
|
||||
let comment: [String]
|
||||
let link: [String]
|
||||
let markdownHeading: [String]
|
||||
}
|
||||
|
||||
private func canonicalSyntaxLanguage(_ language: String) -> String {
|
||||
let normalized = language
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
switch normalized {
|
||||
case "py", "python3":
|
||||
return "python"
|
||||
case "js", "mjs", "cjs":
|
||||
return "javascript"
|
||||
case "ts", "tsx":
|
||||
return "typescript"
|
||||
case "ee", "expression-engine", "expression_engine":
|
||||
return "expressionengine"
|
||||
case "latex", "bibtex":
|
||||
return "tex"
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
func syntaxEmphasisPatterns(
|
||||
for language: String,
|
||||
profile: SyntaxPatternProfile = .full
|
||||
) -> SyntaxEmphasisPatterns {
|
||||
switch canonicalSyntaxLanguage(language) {
|
||||
case "swift":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [
|
||||
"\\b(func|struct|class|enum|protocol|extension|actor|if|else|for|while|switch|case|default|guard|defer|throw|try|catch|return|init|deinit|import|typealias|associatedtype|where|public|private|fileprivate|internal|open|static|mutating|nonmutating|inout|async|await|throws|rethrows)\\b",
|
||||
"(?m)^#(if|elseif|else|endif|warning|error|available)\\b.*$"
|
||||
],
|
||||
comment: [
|
||||
"//.*",
|
||||
"/\\*([^*]|(\\*+[^*/]))*\\*+/",
|
||||
"(?m)^(///).*$",
|
||||
"/\\*\\*([\\s\\S]*?)\\*+/"
|
||||
],
|
||||
link: [
|
||||
"https?://[A-Za-z0-9._~:/?#@!$&'()*+,;=%-]+",
|
||||
"file://[A-Za-z0-9._~:/?#@!$&'()*+,;=%-]+"
|
||||
],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "python":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: ["\\b(def|class|if|else|elif|for|while|try|except|with|as|import|from|return|yield|async|await)\\b"],
|
||||
comment: ["#.*"],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "javascript":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: ["\\b(function|var|let|const|if|else|for|while|do|try|catch|finally|return|class|extends|new|import|export|async|await)\\b"],
|
||||
comment: ["//.*|/\\*([^*]|(\\*+[^*/]))*\\*+/"],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "php":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(function|class|interface|trait|namespace|use|public|private|protected|static|final|abstract|if|else|elseif|for|foreach|while|do|switch|case|default|return|try|catch|throw|new|echo)\b"#],
|
||||
comment: [#"//.*|#.*|/\*([^*]|(\*+[^*/]))*\*+/"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "expressionengine":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\{if(?::elseif)?\b[^}]*\}|\{\/if\}|\{:else\}"#],
|
||||
comment: [#"\{!--[\s\S]*?--\}"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "html":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [],
|
||||
comment: profile == .htmlFast ? [] : [],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "css":
|
||||
return SyntaxEmphasisPatterns(keyword: [], comment: [], link: [], markdownHeading: [])
|
||||
case "c", "cpp":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: ["\\b(int|float|double|char|void|if|else|for|while|do|switch|case|return)\\b"],
|
||||
comment: ["//.*|/\\*([^*]|(\\*+[^*/]))*\\*+/"],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "json":
|
||||
return SyntaxEmphasisPatterns(keyword: [#"\b(true|false|null)\b"#], comment: [], link: [], markdownHeading: [])
|
||||
case "markdown":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [
|
||||
#"(?m)^```[A-Za-z0-9_-]*\s*$|(?m)^~~~[A-Za-z0-9_-]*\s*$"#,
|
||||
#"(?m)^\s*[-*+]\s+.*$|(?m)^\s*\d+\.\s+.*$"#
|
||||
],
|
||||
comment: [#"(?m)^>\s+.*$"#],
|
||||
link: [
|
||||
#"\[[^\]]+\]\([^)]+\)"#,
|
||||
#"https?://[A-Za-z0-9._~:/?#@!$&'()*+,;=%-]+"#
|
||||
],
|
||||
markdownHeading: [
|
||||
#"(?m)^\s{0,3}#{1,6}\s+.*$"#,
|
||||
#"(?m)^\s{0,3}(=+|-+)\s*$"#
|
||||
]
|
||||
)
|
||||
case "tex":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\\[A-Za-z@]+(\*?)"#],
|
||||
comment: [#"(?m)%.*$"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "bash":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(if|then|else|elif|fi|for|while|do|done|case|esac|function|in|select|until|time)\b"#],
|
||||
comment: [#"#.*"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "zsh":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: ["\\b(if|then|else|elif|fi|for|while|do|done|case|esac|function|in|autoload|typeset|setopt|unsetopt)\\b"],
|
||||
comment: ["#.*"],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "powershell":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(function|param|if|else|elseif|foreach|for|while|switch|break|continue|return|try|catch|finally)\b"#],
|
||||
comment: [#"#.*"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "java":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(class|interface|enum|public|private|protected|static|final|void|int|double|float|boolean|new|return|if|else|for|while|switch|case)\b"#],
|
||||
comment: [#"//.*|/\*([^*]|(\*+[^*/]))*\*+/"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "kotlin":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(class|object|fun|val|var|when|if|else|for|while|return|import|package|interface)\b"#],
|
||||
comment: [#"//.*|/\*([^*]|(\*+[^*/]))*\*+/"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "go":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(package|import|func|var|const|type|struct|interface|if|else|for|switch|case|return|go|defer)\b"#],
|
||||
comment: [#"//.*|/\*([^*]|(\*+[^*/]))*\*+/"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "ruby":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(def|class|module|if|else|elsif|end|do|while|until|case|when|begin|rescue|ensure|return)\b"#],
|
||||
comment: [#"#.*"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "rust":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(fn|let|mut|struct|enum|impl|trait|pub|use|mod|if|else|match|loop|while|for|return)\b"#],
|
||||
comment: [#"//.*|/\*([^*]|(\*+[^*/]))*\*+/"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "typescript":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(function|class|interface|type|enum|const|let|var|if|else|for|while|do|try|catch|return|extends|implements)\b"#],
|
||||
comment: [#"//.*|/\*([^*]|(\*+[^*/]))*\*+/"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "objective-c":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(if|else|for|while|switch|case|return)\b"#],
|
||||
comment: [#"//.*|/\*([^*]|(\*+[^*/]))*\*+/"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "sql":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(SELECT|INSERT|UPDATE|DELETE|CREATE|TABLE|FROM|WHERE|JOIN|LEFT|RIGHT|INNER|OUTER|GROUP|BY|ORDER|LIMIT|VALUES|INTO)\b"#],
|
||||
comment: [#"--.*"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "xml":
|
||||
return SyntaxEmphasisPatterns(keyword: [], comment: [], link: [], markdownHeading: [])
|
||||
case "yaml":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"(?m)^\s*-\s+.*$"#, #"\b(true|false|null|yes|no|on|off)\b"#],
|
||||
comment: [#"(?m)^\s*#.*$"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "toml":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(true|false)\b"#],
|
||||
comment: [#"(?m)#.*$"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "csv":
|
||||
return SyntaxEmphasisPatterns(keyword: [], comment: [], link: [], markdownHeading: [])
|
||||
case "ini":
|
||||
return SyntaxEmphasisPatterns(keyword: [], comment: ["^;.*$"], link: [], markdownHeading: [])
|
||||
case "vim":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(set|let|if|endif|for|endfor|while|endwhile|function|endfunction|command|autocmd|syntax|highlight|nnoremap|inoremap|vnoremap|map|nmap|imap|vmap)\b"#],
|
||||
comment: [#"^\s*\".*$"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "log":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(ERROR|ERR|FATAL|WARN|WARNING|INFO|DEBUG|TRACE)\b"#],
|
||||
comment: [],
|
||||
link: [#"https?://[A-Za-z0-9._~:/?#@!$&'()*+,;=%-]+"#],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "ipynb":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(true|false|null)\b"#],
|
||||
comment: [],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "csharp":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(class|interface|enum|struct|namespace|using|public|private|protected|internal|static|readonly|sealed|abstract|virtual|override|async|await|new|return|if|else|for|foreach|while|do|switch|case|break|continue|try|catch|finally|throw)\b"#],
|
||||
comment: [#"//.*|/\*([^*]|(\*+[^*/]))*\*+/"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "cobol":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"(?i)\b(identification|environment|data|procedure|division|section|program-id|author|installati?on|date-written|date-compiled|working-storage|linkage|file-control|input-output|select|assign|fd|01|77|88|level|pic|picture|value|values|move|add|subtract|multiply|divide|compute|if|else|end-if|evaluate|when|perform|until|varying|go|to|goback|stop|run|call|accept|display|open|close|read|write|rewrite|delete|string|unstring|initialize|set|inspect)\b"#],
|
||||
comment: [#"(?m)^\s*\*.*$|(?m)^\s*\*>.*$"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "dotenv":
|
||||
return SyntaxEmphasisPatterns(keyword: [], comment: [#"(?m)#.*$"#], link: [], markdownHeading: [])
|
||||
case "proto":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(syntax|package|import|option|message|enum|service|rpc|returns|repeated|map|oneof|reserved|required|optional)\b"#],
|
||||
comment: [#"//.*|/\*([^*]|(\*+[^*/]))*\*+/"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "graphql":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(type|interface|enum|union|input|scalar|schema|extend|implements|directive|on|query|mutation|subscription|fragment)\b"#],
|
||||
comment: [#"(?m)#.*$"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "rst":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"(?m)^\s*([=\-`:'\"~^_*+<>#]{3,})\s*$"#],
|
||||
comment: [#"(?m)#.*$"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "nginx":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(http|server|location|upstream|map|if|set|return|rewrite|proxy_pass|listen|server_name|root|index|try_files|include|error_page|access_log|error_log|gzip|ssl|add_header)\b"#],
|
||||
comment: [#"(?m)#.*$"#],
|
||||
link: [],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "standard":
|
||||
return SyntaxEmphasisPatterns(
|
||||
keyword: [#"\b(if|else|for|while|do|switch|case|return|class|struct|enum|func|function|var|let|const|import|from|using|namespace|public|private|protected|static|void|new|try|catch|finally|throw)\b"#],
|
||||
comment: [#"//.*|/\*([^*]|(\*+[^*/]))*\*+/|#.*"#],
|
||||
link: [#"https?://[A-Za-z0-9._~:/?#@!$&'()*+,;=%-]+"#],
|
||||
markdownHeading: []
|
||||
)
|
||||
case "plain":
|
||||
return SyntaxEmphasisPatterns(keyword: [], comment: [], link: [], markdownHeading: [])
|
||||
default:
|
||||
return SyntaxEmphasisPatterns(keyword: [], comment: [], link: [], markdownHeading: [])
|
||||
}
|
||||
}
|
||||
|
||||
// Regex patterns per language mapped to colors. Keep light-weight for performance.
|
||||
func getSyntaxPatterns(
|
||||
for language: String,
|
||||
colors: SyntaxColors,
|
||||
profile: SyntaxPatternProfile = .full
|
||||
) -> [String: Color] {
|
||||
let normalized = language
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
let canonical: String
|
||||
switch normalized {
|
||||
case "py", "python3":
|
||||
canonical = "python"
|
||||
case "js", "mjs", "cjs":
|
||||
canonical = "javascript"
|
||||
case "ts", "tsx":
|
||||
canonical = "typescript"
|
||||
case "ee", "expression-engine", "expression_engine":
|
||||
canonical = "expressionengine"
|
||||
case "latex", "bibtex":
|
||||
canonical = "tex"
|
||||
default:
|
||||
canonical = normalized
|
||||
}
|
||||
let canonical = canonicalSyntaxLanguage(language)
|
||||
switch canonical {
|
||||
case "swift":
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import SwiftUI
|
||||
import Foundation
|
||||
import Dispatch
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#elseif canImport(UIKit)
|
||||
|
|
@ -638,7 +642,7 @@ extension ContentView {
|
|||
continue
|
||||
}
|
||||
window.isOpaque = !enabled
|
||||
window.backgroundColor = enabled ? .clear : NSColor.windowBackgroundColor
|
||||
window.backgroundColor = editorTranslucentBackgroundColor(enabled: enabled, window: window)
|
||||
// Keep chrome flags constant; toggling these causes visible top-bar jumps.
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.toolbarStyle = .unified
|
||||
|
|
@ -650,6 +654,28 @@ extension ContentView {
|
|||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func editorTranslucentBackgroundColor(enabled: Bool, window: NSWindow) -> NSColor {
|
||||
guard enabled else { return NSColor.windowBackgroundColor }
|
||||
let isDark = window.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
|
||||
let modeRaw = UserDefaults.standard.string(forKey: "SettingsMacTranslucencyMode") ?? "balanced"
|
||||
let whiteLevel: CGFloat
|
||||
let alpha: CGFloat
|
||||
switch modeRaw {
|
||||
case "subtle":
|
||||
whiteLevel = isDark ? 0.18 : 0.90
|
||||
alpha = 0.86
|
||||
case "vibrant":
|
||||
whiteLevel = isDark ? 0.12 : 0.82
|
||||
alpha = 0.72
|
||||
default:
|
||||
whiteLevel = isDark ? 0.15 : 0.86
|
||||
alpha = 0.79
|
||||
}
|
||||
return NSColor(calibratedWhite: whiteLevel, alpha: alpha)
|
||||
}
|
||||
#endif
|
||||
|
||||
func openProjectFolder() {
|
||||
#if os(macOS)
|
||||
let panel = NSOpenPanel()
|
||||
|
|
@ -682,6 +708,95 @@ extension ContentView {
|
|||
}
|
||||
}
|
||||
|
||||
func refreshProjectBrowserState() {
|
||||
refreshProjectTree()
|
||||
refreshProjectFileIndex()
|
||||
}
|
||||
|
||||
func refreshProjectFileIndex() {
|
||||
guard let root = projectRootFolderURL else {
|
||||
#if os(macOS)
|
||||
stopProjectFolderObservation()
|
||||
#endif
|
||||
projectFileIndexTask?.cancel()
|
||||
projectFileIndexTask = nil
|
||||
projectFileIndexRefreshGeneration &+= 1
|
||||
indexedProjectFileURLs = []
|
||||
isProjectFileIndexing = false
|
||||
return
|
||||
}
|
||||
|
||||
projectFileIndexTask?.cancel()
|
||||
projectFileIndexRefreshGeneration &+= 1
|
||||
let generation = projectFileIndexRefreshGeneration
|
||||
let supportedOnly = showSupportedProjectFilesOnly
|
||||
isProjectFileIndexing = true
|
||||
|
||||
projectFileIndexTask = Task(priority: .utility) {
|
||||
let urls = await ProjectFileIndex.buildFileURLs(
|
||||
at: root,
|
||||
supportedOnly: supportedOnly,
|
||||
isSupportedFile: { url in
|
||||
EditorViewModel.isSupportedEditorFileURL(url)
|
||||
}
|
||||
)
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
guard generation == projectFileIndexRefreshGeneration else { return }
|
||||
guard projectRootFolderURL?.standardizedFileURL == root.standardizedFileURL else { return }
|
||||
indexedProjectFileURLs = urls
|
||||
isProjectFileIndexing = false
|
||||
projectFileIndexTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
func stopProjectFolderObservation() {
|
||||
pendingProjectFolderRefreshWorkItem?.cancel()
|
||||
pendingProjectFolderRefreshWorkItem = nil
|
||||
projectFolderMonitorSource?.cancel()
|
||||
projectFolderMonitorSource = nil
|
||||
}
|
||||
|
||||
func startProjectFolderObservation(for root: URL) {
|
||||
stopProjectFolderObservation()
|
||||
|
||||
let fileDescriptor = open(root.path, O_EVTONLY)
|
||||
guard fileDescriptor >= 0 else { return }
|
||||
|
||||
let source = DispatchSource.makeFileSystemObjectSource(
|
||||
fileDescriptor: fileDescriptor,
|
||||
eventMask: [.write, .delete, .rename, .extend, .attrib, .link, .revoke],
|
||||
queue: DispatchQueue.global(qos: .utility)
|
||||
)
|
||||
|
||||
source.setEventHandler { [root] in
|
||||
DispatchQueue.main.async {
|
||||
guard self.projectRootFolderURL?.standardizedFileURL == root.standardizedFileURL else { return }
|
||||
self.pendingProjectFolderRefreshWorkItem?.cancel()
|
||||
let workItem = DispatchWorkItem { [root] in
|
||||
guard self.projectRootFolderURL?.standardizedFileURL == root.standardizedFileURL else { return }
|
||||
self.refreshProjectBrowserState()
|
||||
}
|
||||
self.pendingProjectFolderRefreshWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: workItem)
|
||||
}
|
||||
}
|
||||
|
||||
source.setCancelHandler {
|
||||
close(fileDescriptor)
|
||||
}
|
||||
|
||||
projectFolderMonitorSource = source
|
||||
source.resume()
|
||||
}
|
||||
#else
|
||||
func stopProjectFolderObservation() {}
|
||||
|
||||
func startProjectFolderObservation(for root: URL) {}
|
||||
#endif
|
||||
|
||||
func openProjectFile(url: URL) {
|
||||
guard EditorViewModel.isSupportedEditorFileURL(url) else {
|
||||
presentUnsupportedFileAlert(for: url)
|
||||
|
|
@ -689,7 +804,9 @@ extension ContentView {
|
|||
}
|
||||
if !viewModel.openFile(url: url) {
|
||||
presentUnsupportedFileAlert(for: url)
|
||||
return
|
||||
}
|
||||
persistSessionIfReady()
|
||||
}
|
||||
|
||||
private nonisolated static func buildProjectTree(at root: URL, supportedOnly: Bool) -> [ProjectTreeNode] {
|
||||
|
|
@ -718,8 +835,13 @@ extension ContentView {
|
|||
projectRootFolderURL = folderURL
|
||||
projectTreeNodes = []
|
||||
quickSwitcherProjectFileURLs = []
|
||||
indexedProjectFileURLs = []
|
||||
isProjectFileIndexing = false
|
||||
safeModeRecoveryPreparedForNextLaunch = false
|
||||
applyProjectEditorOverrides(from: folderURL)
|
||||
refreshProjectTree()
|
||||
startProjectFolderObservation(for: folderURL)
|
||||
refreshProjectBrowserState()
|
||||
persistSessionIfReady()
|
||||
}
|
||||
|
||||
func clearProjectEditorOverrides() {
|
||||
|
|
@ -813,14 +935,17 @@ extension ContentView {
|
|||
|
||||
nonisolated static func findInFiles(
|
||||
root: URL,
|
||||
candidateFiles: [URL]?,
|
||||
query: String,
|
||||
caseSensitive: Bool,
|
||||
maxResults: Int
|
||||
) async -> [FindInFilesMatch] {
|
||||
await Task.detached(priority: .userInitiated) {
|
||||
let searchFiles = searchCandidateFiles(root: root, candidateFiles: candidateFiles)
|
||||
#if os(macOS)
|
||||
if let ripgrepMatches = findInFilesWithRipgrep(
|
||||
root: root,
|
||||
candidateFiles: searchFiles,
|
||||
query: query,
|
||||
caseSensitive: caseSensitive,
|
||||
maxResults: maxResults
|
||||
|
|
@ -829,11 +954,10 @@ extension ContentView {
|
|||
}
|
||||
#endif
|
||||
|
||||
let files = searchableProjectFiles(at: root)
|
||||
var results: [FindInFilesMatch] = []
|
||||
results.reserveCapacity(min(maxResults, 200))
|
||||
|
||||
for file in files {
|
||||
for file in searchFiles {
|
||||
if Task.isCancelled || results.count >= maxResults { break }
|
||||
let matches = findMatches(
|
||||
in: file,
|
||||
|
|
@ -852,6 +976,7 @@ extension ContentView {
|
|||
#if os(macOS)
|
||||
private nonisolated static func findInFilesWithRipgrep(
|
||||
root: URL,
|
||||
candidateFiles: [URL],
|
||||
query: String,
|
||||
caseSensitive: Bool,
|
||||
maxResults: Int
|
||||
|
|
@ -860,7 +985,7 @@ extension ContentView {
|
|||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
process.currentDirectoryURL = root
|
||||
process.arguments = [
|
||||
var arguments = [
|
||||
"rg",
|
||||
"--json",
|
||||
"--line-number",
|
||||
|
|
@ -868,9 +993,14 @@ extension ContentView {
|
|||
"--max-count",
|
||||
String(maxResults),
|
||||
caseSensitive ? "-s" : "-i",
|
||||
query,
|
||||
root.path
|
||||
query
|
||||
]
|
||||
if let ripgrepFileArguments = ripgrepPathArguments(root: root, candidateFiles: candidateFiles) {
|
||||
arguments.append(contentsOf: ripgrepFileArguments)
|
||||
} else {
|
||||
arguments.append(root.path)
|
||||
}
|
||||
process.arguments = arguments
|
||||
|
||||
let outputPipe = Pipe()
|
||||
process.standardOutput = outputPipe
|
||||
|
|
@ -953,6 +1083,42 @@ extension ContentView {
|
|||
}
|
||||
#endif
|
||||
|
||||
private nonisolated static func searchCandidateFiles(root: URL, candidateFiles: [URL]?) -> [URL] {
|
||||
if let candidateFiles, !candidateFiles.isEmpty {
|
||||
return candidateFiles
|
||||
}
|
||||
return searchableProjectFiles(at: root)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private nonisolated static func ripgrepPathArguments(root: URL, candidateFiles: [URL]) -> [String]? {
|
||||
guard !candidateFiles.isEmpty else { return [] }
|
||||
var arguments: [String] = []
|
||||
arguments.reserveCapacity(candidateFiles.count)
|
||||
var combinedLength = 0
|
||||
|
||||
for fileURL in candidateFiles {
|
||||
let candidatePath: String
|
||||
let standardizedFileURL = fileURL.standardizedFileURL
|
||||
let standardizedRoot = root.standardizedFileURL
|
||||
if standardizedFileURL.path.hasPrefix(standardizedRoot.path + "/") {
|
||||
candidatePath = String(standardizedFileURL.path.dropFirst(standardizedRoot.path.count + 1))
|
||||
} else if standardizedFileURL == standardizedRoot {
|
||||
candidatePath = standardizedFileURL.lastPathComponent
|
||||
} else {
|
||||
candidatePath = standardizedFileURL.path
|
||||
}
|
||||
combinedLength += candidatePath.utf8.count + 1
|
||||
if candidateFiles.count > 2_000 || combinedLength > 120_000 {
|
||||
return nil
|
||||
}
|
||||
arguments.append(candidatePath)
|
||||
}
|
||||
|
||||
return arguments
|
||||
}
|
||||
#endif
|
||||
|
||||
private nonisolated static func searchableProjectFiles(at root: URL) -> [URL] {
|
||||
let fm = FileManager.default
|
||||
let keys: Set<URLResourceKey> = [.isRegularFileKey, .isHiddenKey, .fileSizeKey]
|
||||
|
|
|
|||
683
Neon Vision Editor/UI/ContentView+MarkdownPreviewExport.swift
Normal file
683
Neon Vision Editor/UI/ContentView+MarkdownPreviewExport.swift
Normal file
|
|
@ -0,0 +1,683 @@
|
|||
import Foundation
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
#endif
|
||||
|
||||
extension ContentView {
|
||||
enum MarkdownPDFExportMode: String {
|
||||
case paginatedFit = "paginated-fit"
|
||||
case onePageFit = "one-page-fit"
|
||||
}
|
||||
|
||||
var markdownPDFExportMode: MarkdownPDFExportMode {
|
||||
MarkdownPDFExportMode(rawValue: markdownPDFExportModeRaw) ?? .paginatedFit
|
||||
}
|
||||
|
||||
var markdownPDFRendererMode: MarkdownPreviewPDFRenderer.ExportMode {
|
||||
switch markdownPDFExportMode {
|
||||
case .onePageFit:
|
||||
return .onePageFit
|
||||
case .paginatedFit:
|
||||
return .paginatedFit
|
||||
}
|
||||
}
|
||||
|
||||
var markdownPreviewTemplate: String {
|
||||
switch markdownPreviewTemplateRaw {
|
||||
case "docs",
|
||||
"article",
|
||||
"compact",
|
||||
"github-docs",
|
||||
"academic-paper",
|
||||
"terminal-notes",
|
||||
"magazine",
|
||||
"minimal-reader",
|
||||
"presentation",
|
||||
"night-contrast",
|
||||
"warm-sepia",
|
||||
"dense-compact",
|
||||
"developer-spec":
|
||||
return markdownPreviewTemplateRaw
|
||||
default:
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
var markdownPreviewPreferDarkMode: Bool {
|
||||
if let forcedScheme = ReleaseRuntimePolicy.preferredColorScheme(for: appearance) {
|
||||
return forcedScheme == .dark
|
||||
}
|
||||
return colorScheme == .dark
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func exportMarkdownPreviewPDF() {
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let exportSource = await markdownExportSourceText()
|
||||
let html = markdownPreviewExportHTML(from: exportSource, mode: markdownPDFExportMode)
|
||||
guard markdownExportHasContrastContract(html) else {
|
||||
throw NSError(
|
||||
domain: "MarkdownPreviewExport",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "PDF export contrast guard failed."]
|
||||
)
|
||||
}
|
||||
let pdfData = try await MarkdownPreviewPDFRenderer.render(
|
||||
html: html,
|
||||
mode: markdownPDFRendererMode
|
||||
)
|
||||
let filename = suggestedMarkdownPDFFilename()
|
||||
#if os(macOS)
|
||||
try saveMarkdownPreviewPDFOnMac(pdfData, suggestedFilename: filename)
|
||||
#else
|
||||
markdownPDFExportDocument = PDFExportDocument(data: pdfData)
|
||||
markdownPDFExportFilename = filename
|
||||
showMarkdownPDFExporter = true
|
||||
#endif
|
||||
} catch {
|
||||
markdownPDFExportErrorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func markdownExportSourceText() async -> String {
|
||||
guard let fileURL = viewModel.selectedTab?.fileURL else { return currentContent }
|
||||
let fallback = currentContent
|
||||
return await Task.detached(priority: .userInitiated) {
|
||||
let didAccess = fileURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if didAccess {
|
||||
fileURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
guard let data = try? Data(contentsOf: fileURL, options: [.mappedIfSafe]) else {
|
||||
return fallback
|
||||
}
|
||||
if let utf8 = String(data: data, encoding: .utf8) { return utf8 }
|
||||
if let utf16LE = String(data: data, encoding: .utf16LittleEndian) { return utf16LE }
|
||||
if let utf16BE = String(data: data, encoding: .utf16BigEndian) { return utf16BE }
|
||||
if let utf32LE = String(data: data, encoding: .utf32LittleEndian) { return utf32LE }
|
||||
if let utf32BE = String(data: data, encoding: .utf32BigEndian) { return utf32BE }
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}.value
|
||||
}
|
||||
|
||||
func suggestedMarkdownPDFFilename() -> String {
|
||||
let tabName = viewModel.selectedTab?.name ?? "Markdown-Preview"
|
||||
let rawName = URL(fileURLWithPath: tabName).deletingPathExtension().lastPathComponent
|
||||
let safeBase = rawName.isEmpty ? "Markdown-Preview" : rawName
|
||||
return "\(safeBase)-Preview.pdf"
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
@MainActor
|
||||
func saveMarkdownPreviewPDFOnMac(_ data: Data, suggestedFilename: String) throws {
|
||||
let panel = NSSavePanel()
|
||||
panel.title = "Export Markdown Preview as PDF"
|
||||
panel.nameFieldStringValue = suggestedFilename
|
||||
panel.canCreateDirectories = true
|
||||
panel.allowedContentTypes = [.pdf]
|
||||
guard panel.runModal() == .OK else { return }
|
||||
guard let destinationURL = panel.url else { return }
|
||||
try data.write(to: destinationURL, options: .atomic)
|
||||
}
|
||||
#endif
|
||||
|
||||
var markdownPreviewRenderByteLimit: Int { 180_000 }
|
||||
var markdownPreviewFallbackCharacterLimit: Int { 120_000 }
|
||||
|
||||
func markdownPreviewHTML(from markdownText: String, preferDarkMode: Bool) -> String {
|
||||
let bodyHTML = markdownPreviewBodyHTML(from: markdownText, useRenderLimits: true)
|
||||
return """
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
\(markdownPreviewCSS(template: markdownPreviewTemplate, preferDarkMode: preferDarkMode))
|
||||
</style>
|
||||
</head>
|
||||
<body class="\(markdownPreviewTemplate)">
|
||||
<main class="content">
|
||||
\(bodyHTML)
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
}
|
||||
|
||||
func markdownPreviewExportHTML(from markdownText: String, mode: MarkdownPDFExportMode) -> String {
|
||||
let bodyHTML = markdownPreviewBodyHTML(from: markdownText, useRenderLimits: false)
|
||||
let modeClass = mode == .onePageFit ? " pdf-one-page" : ""
|
||||
return """
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
\(markdownPreviewCSS(template: markdownPreviewTemplate))
|
||||
</style>
|
||||
</head>
|
||||
<body class="\(markdownPreviewTemplate) pdf-export\(modeClass)">
|
||||
<main class="content">
|
||||
\(bodyHTML)
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
}
|
||||
|
||||
func markdownExportHasContrastContract(_ html: String) -> Bool {
|
||||
html.contains("body.pdf-export") &&
|
||||
html.contains("background: #ffffff") &&
|
||||
html.contains("-webkit-text-fill-color: #111827")
|
||||
}
|
||||
|
||||
func markdownPreviewBodyHTML(from markdownText: String, useRenderLimits: Bool) -> String {
|
||||
let byteCount = markdownText.lengthOfBytes(using: .utf8)
|
||||
if useRenderLimits && byteCount > markdownPreviewRenderByteLimit {
|
||||
return largeMarkdownFallbackHTML(from: markdownText, byteCount: byteCount)
|
||||
}
|
||||
if !useRenderLimits && byteCount > markdownPreviewRenderByteLimit {
|
||||
return "<pre>\(escapedHTML(markdownText))</pre>"
|
||||
}
|
||||
return renderedMarkdownBodyHTML(from: markdownText) ?? "<pre>\(escapedHTML(markdownText))</pre>"
|
||||
}
|
||||
|
||||
func largeMarkdownFallbackHTML(from markdownText: String, byteCount: Int) -> String {
|
||||
let previewText = String(markdownText.prefix(markdownPreviewFallbackCharacterLimit))
|
||||
let truncated = previewText.count < markdownText.count
|
||||
let statusSuffix = truncated ? " (truncated preview)" : ""
|
||||
return """
|
||||
<section class="preview-warning">
|
||||
<p><strong>Large Markdown file</strong></p>
|
||||
<p class="preview-warning-meta">Rendering full Markdown is skipped for stability (\(byteCount) bytes)\(statusSuffix).</p>
|
||||
</section>
|
||||
<pre>\(escapedHTML(previewText))</pre>
|
||||
"""
|
||||
}
|
||||
|
||||
func renderedMarkdownBodyHTML(from markdownText: String) -> String? {
|
||||
let html = simpleMarkdownToHTML(markdownText).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return html.isEmpty ? nil : html
|
||||
}
|
||||
|
||||
func simpleMarkdownToHTML(_ markdown: String) -> String {
|
||||
let lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")
|
||||
var result: [String] = []
|
||||
var paragraphLines: [String] = []
|
||||
var insideCodeFence = false
|
||||
var codeFenceLanguage: String?
|
||||
var insideUnorderedList = false
|
||||
var insideOrderedList = false
|
||||
var insideBlockquote = false
|
||||
|
||||
func flushParagraph() {
|
||||
guard !paragraphLines.isEmpty else { return }
|
||||
let paragraph = paragraphLines.map { inlineMarkdownToHTML($0) }.joined(separator: "<br/>")
|
||||
result.append("<p>\(paragraph)</p>")
|
||||
paragraphLines.removeAll(keepingCapacity: true)
|
||||
}
|
||||
|
||||
func closeLists() {
|
||||
if insideUnorderedList {
|
||||
result.append("</ul>")
|
||||
insideUnorderedList = false
|
||||
}
|
||||
if insideOrderedList {
|
||||
result.append("</ol>")
|
||||
insideOrderedList = false
|
||||
}
|
||||
}
|
||||
|
||||
func closeBlockquote() {
|
||||
if insideBlockquote {
|
||||
flushParagraph()
|
||||
closeLists()
|
||||
result.append("</blockquote>")
|
||||
insideBlockquote = false
|
||||
}
|
||||
}
|
||||
|
||||
func closeParagraphAndInlineContainers() {
|
||||
flushParagraph()
|
||||
closeLists()
|
||||
}
|
||||
|
||||
for rawLine in lines {
|
||||
let trimmed = rawLine.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
if trimmed.hasPrefix("```") {
|
||||
if insideCodeFence {
|
||||
result.append("</code></pre>")
|
||||
insideCodeFence = false
|
||||
codeFenceLanguage = nil
|
||||
} else {
|
||||
closeBlockquote()
|
||||
closeParagraphAndInlineContainers()
|
||||
insideCodeFence = true
|
||||
let lang = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces)
|
||||
codeFenceLanguage = lang.isEmpty ? nil : lang
|
||||
if let codeFenceLanguage {
|
||||
result.append("<pre><code class=\"language-\(escapedHTML(codeFenceLanguage))\">")
|
||||
} else {
|
||||
result.append("<pre><code>")
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if insideCodeFence {
|
||||
result.append("\(escapedHTML(rawLine))\n")
|
||||
continue
|
||||
}
|
||||
|
||||
if trimmed.isEmpty {
|
||||
closeParagraphAndInlineContainers()
|
||||
closeBlockquote()
|
||||
continue
|
||||
}
|
||||
|
||||
if let heading = markdownHeading(from: trimmed) {
|
||||
closeBlockquote()
|
||||
closeParagraphAndInlineContainers()
|
||||
result.append("<h\(heading.level)>\(inlineMarkdownToHTML(heading.text))</h\(heading.level)>")
|
||||
continue
|
||||
}
|
||||
|
||||
if isMarkdownHorizontalRule(trimmed) {
|
||||
closeBlockquote()
|
||||
closeParagraphAndInlineContainers()
|
||||
result.append("<hr/>")
|
||||
continue
|
||||
}
|
||||
|
||||
var workingLine = trimmed
|
||||
let isBlockquoteLine = workingLine.hasPrefix(">")
|
||||
if isBlockquoteLine {
|
||||
if !insideBlockquote {
|
||||
closeParagraphAndInlineContainers()
|
||||
result.append("<blockquote>")
|
||||
insideBlockquote = true
|
||||
}
|
||||
workingLine = workingLine.dropFirst().trimmingCharacters(in: .whitespaces)
|
||||
} else {
|
||||
closeBlockquote()
|
||||
}
|
||||
|
||||
if let unordered = markdownUnorderedListItem(from: workingLine) {
|
||||
flushParagraph()
|
||||
if insideOrderedList {
|
||||
result.append("</ol>")
|
||||
insideOrderedList = false
|
||||
}
|
||||
if !insideUnorderedList {
|
||||
result.append("<ul>")
|
||||
insideUnorderedList = true
|
||||
}
|
||||
result.append("<li>\(inlineMarkdownToHTML(unordered))</li>")
|
||||
continue
|
||||
}
|
||||
|
||||
if let ordered = markdownOrderedListItem(from: workingLine) {
|
||||
flushParagraph()
|
||||
if insideUnorderedList {
|
||||
result.append("</ul>")
|
||||
insideUnorderedList = false
|
||||
}
|
||||
if !insideOrderedList {
|
||||
result.append("<ol>")
|
||||
insideOrderedList = true
|
||||
}
|
||||
result.append("<li>\(inlineMarkdownToHTML(ordered))</li>")
|
||||
continue
|
||||
}
|
||||
|
||||
closeLists()
|
||||
paragraphLines.append(workingLine)
|
||||
}
|
||||
|
||||
closeBlockquote()
|
||||
closeParagraphAndInlineContainers()
|
||||
if insideCodeFence {
|
||||
result.append("</code></pre>")
|
||||
}
|
||||
return result.joined(separator: "\n")
|
||||
}
|
||||
|
||||
func markdownHeading(from line: String) -> (level: Int, text: String)? {
|
||||
let pattern = "^(#{1,6})\\s+(.+)$"
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let range = NSRange(line.startIndex..., in: line)
|
||||
guard let match = regex.firstMatch(in: line, options: [], range: range),
|
||||
let hashesRange = Range(match.range(at: 1), in: line),
|
||||
let textRange = Range(match.range(at: 2), in: line) else {
|
||||
return nil
|
||||
}
|
||||
return (line[hashesRange].count, String(line[textRange]))
|
||||
}
|
||||
|
||||
func isMarkdownHorizontalRule(_ line: String) -> Bool {
|
||||
let compact = line.replacingOccurrences(of: " ", with: "")
|
||||
return compact == "***" || compact == "---" || compact == "___"
|
||||
}
|
||||
|
||||
func markdownUnorderedListItem(from line: String) -> String? {
|
||||
let pattern = "^[-*+]\\s+(.+)$"
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let range = NSRange(line.startIndex..., in: line)
|
||||
guard let match = regex.firstMatch(in: line, options: [], range: range),
|
||||
let textRange = Range(match.range(at: 1), in: line) else {
|
||||
return nil
|
||||
}
|
||||
return String(line[textRange])
|
||||
}
|
||||
|
||||
func markdownOrderedListItem(from line: String) -> String? {
|
||||
let pattern = "^\\d+[\\.)]\\s+(.+)$"
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let range = NSRange(line.startIndex..., in: line)
|
||||
guard let match = regex.firstMatch(in: line, options: [], range: range),
|
||||
let textRange = Range(match.range(at: 1), in: line) else {
|
||||
return nil
|
||||
}
|
||||
return String(line[textRange])
|
||||
}
|
||||
|
||||
func inlineMarkdownToHTML(_ text: String) -> String {
|
||||
var html = escapedHTML(text)
|
||||
var codeSpans: [String] = []
|
||||
let codeSpanTokenPrefix = "%%CODESPAN"
|
||||
let codeSpanTokenSuffix = "%%"
|
||||
|
||||
html = replacingRegex(in: html, pattern: "`([^`]+)`") { match in
|
||||
let content = String(match.dropFirst().dropLast())
|
||||
let token = "\(codeSpanTokenPrefix)\(codeSpans.count)\(codeSpanTokenSuffix)"
|
||||
codeSpans.append("<code>\(content)</code>")
|
||||
return token
|
||||
}
|
||||
|
||||
html = replacingRegex(in: html, pattern: "!\\[([^\\]]*)\\]\\(([^\\)\\s]+)\\)") { match in
|
||||
let parts = captureGroups(in: match, pattern: "!\\[([^\\]]*)\\]\\(([^\\)\\s]+)\\)")
|
||||
guard parts.count == 2 else { return match }
|
||||
return "<img src=\"\(parts[1])\" alt=\"\(parts[0])\"/>"
|
||||
}
|
||||
|
||||
html = replacingRegex(in: html, pattern: "\\[([^\\]]+)\\]\\(([^\\)\\s]+)\\)") { match in
|
||||
let parts = captureGroups(in: match, pattern: "\\[([^\\]]+)\\]\\(([^\\)\\s]+)\\)")
|
||||
guard parts.count == 2 else { return match }
|
||||
return "<a href=\"\(parts[1])\">\(parts[0])</a>"
|
||||
}
|
||||
|
||||
html = replacingRegex(in: html, pattern: "\\*\\*([^*]+)\\*\\*") { "<strong>\(String($0.dropFirst(2).dropLast(2)))</strong>" }
|
||||
html = replacingRegex(in: html, pattern: "__([^_]+)__") { "<strong>\(String($0.dropFirst(2).dropLast(2)))</strong>" }
|
||||
html = replacingRegex(in: html, pattern: "\\*([^*]+)\\*") { "<em>\(String($0.dropFirst().dropLast()))</em>" }
|
||||
html = replacingRegex(in: html, pattern: "_([^_]+)_") { "<em>\(String($0.dropFirst().dropLast()))</em>" }
|
||||
|
||||
for (index, codeHTML) in codeSpans.enumerated() {
|
||||
html = html.replacingOccurrences(
|
||||
of: "\(codeSpanTokenPrefix)\(index)\(codeSpanTokenSuffix)",
|
||||
with: codeHTML
|
||||
)
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
func replacingRegex(in text: String, pattern: String, transform: (String) -> String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
|
||||
let matches = regex.matches(in: text, options: [], range: NSRange(text.startIndex..., in: text))
|
||||
guard !matches.isEmpty else { return text }
|
||||
|
||||
var output = text
|
||||
for match in matches.reversed() {
|
||||
guard let range = Range(match.range, in: output) else { continue }
|
||||
let segment = String(output[range])
|
||||
output.replaceSubrange(range, with: transform(segment))
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func captureGroups(in text: String, pattern: String) -> [String] {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern),
|
||||
let match = regex.firstMatch(in: text, options: [], range: NSRange(text.startIndex..., in: text)) else {
|
||||
return []
|
||||
}
|
||||
var groups: [String] = []
|
||||
for idx in 1..<match.numberOfRanges {
|
||||
if let range = Range(match.range(at: idx), in: text) {
|
||||
groups.append(String(text[range]))
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func markdownPreviewCSS(template: String, preferDarkMode: Bool = false) -> String {
|
||||
let basePadding: String
|
||||
let fontSize: String
|
||||
let lineHeight: String
|
||||
let maxWidth: String
|
||||
switch template {
|
||||
case "docs":
|
||||
basePadding = "22px 30px"
|
||||
fontSize = "15px"
|
||||
lineHeight = "1.7"
|
||||
maxWidth = "900px"
|
||||
case "article":
|
||||
basePadding = "32px 48px"
|
||||
fontSize = "17px"
|
||||
lineHeight = "1.8"
|
||||
maxWidth = "760px"
|
||||
case "compact", "dense-compact":
|
||||
basePadding = "14px 16px"
|
||||
fontSize = "13px"
|
||||
lineHeight = "1.5"
|
||||
maxWidth = "none"
|
||||
default:
|
||||
basePadding = "18px 22px"
|
||||
fontSize = "14px"
|
||||
lineHeight = "1.6"
|
||||
maxWidth = "860px"
|
||||
}
|
||||
|
||||
let textColor = preferDarkMode ? "#E5E7EB" : "#111827"
|
||||
let linkColor = preferDarkMode ? "#7FB0FF" : "#2F7CF6"
|
||||
let previewBackground = preferDarkMode && template == "night-contrast"
|
||||
? "linear-gradient(180deg, #020617 0%, #050816 100%)"
|
||||
: "transparent"
|
||||
|
||||
return """
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--md-text-color: \(textColor);
|
||||
--md-link-color: \(linkColor);
|
||||
}
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: \(previewBackground);
|
||||
color: var(--md-text-color);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
|
||||
font-size: \(fontSize);
|
||||
line-height: \(lineHeight);
|
||||
}
|
||||
.content {
|
||||
max-width: \(maxWidth);
|
||||
padding: \(basePadding);
|
||||
margin: 0 auto;
|
||||
}
|
||||
.preview-warning {
|
||||
margin: 0.5em 0 0.8em;
|
||||
padding: 0.75em 0.9em;
|
||||
border-radius: 9px;
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 45%, transparent);
|
||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
}
|
||||
.preview-warning p {
|
||||
margin: 0;
|
||||
}
|
||||
.preview-warning-meta {
|
||||
margin-top: 0.4em !important;
|
||||
font-size: 0.92em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1.25;
|
||||
margin: 1.1em 0 0.55em;
|
||||
font-weight: 700;
|
||||
}
|
||||
h1 { font-size: 1.85em; border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent); padding-bottom: 0.25em; }
|
||||
h2 { font-size: 1.45em; border-bottom: 1px solid color-mix(in srgb, currentColor 13%, transparent); padding-bottom: 0.2em; }
|
||||
h3 { font-size: 1.2em; }
|
||||
p, ul, ol, blockquote, table, pre { margin: 0.65em 0; }
|
||||
ul, ol { padding-left: 1.3em; }
|
||||
li { margin: 0.2em 0; }
|
||||
blockquote {
|
||||
margin-left: 0;
|
||||
padding: 0.45em 0.9em;
|
||||
border-left: 3px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||
border-radius: 6px;
|
||||
}
|
||||
code {
|
||||
font-family: "SF Mono", "Menlo", "Monaco", monospace;
|
||||
font-size: 0.9em;
|
||||
padding: 0.12em 0.35em;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
}
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
padding: 0.8em 0.95em;
|
||||
border-radius: 9px;
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
|
||||
line-height: 1.35;
|
||||
white-space: pre;
|
||||
}
|
||||
pre code {
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
font-size: 0.88em;
|
||||
line-height: 1.35;
|
||||
white-space: pre;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.45em 0.55em;
|
||||
border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent);
|
||||
}
|
||||
th {
|
||||
background: color-mix(in srgb, currentColor 7%, transparent);
|
||||
font-weight: 600;
|
||||
}
|
||||
a {
|
||||
color: var(--md-link-color);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--md-link-color) 45%, transparent);
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent);
|
||||
margin: 1.1em 0;
|
||||
}
|
||||
body.pdf-export {
|
||||
background: #ffffff !important;
|
||||
color: #111827 !important;
|
||||
}
|
||||
body.pdf-export .content {
|
||||
background: #ffffff !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
body.pdf-export.pdf-one-page .content {
|
||||
max-width: none !important;
|
||||
width: auto !important;
|
||||
}
|
||||
body.pdf-export, body.pdf-export * {
|
||||
opacity: 1 !important;
|
||||
text-shadow: none !important;
|
||||
-webkit-text-fill-color: #111827 !important;
|
||||
}
|
||||
body.pdf-export a {
|
||||
color: #1d4ed8 !important;
|
||||
border-bottom-color: color-mix(in srgb, #1d4ed8 45%, transparent) !important;
|
||||
-webkit-text-fill-color: #1d4ed8 !important;
|
||||
}
|
||||
body.pdf-export code,
|
||||
body.pdf-export pre,
|
||||
body.pdf-export pre code {
|
||||
color: #111827 !important;
|
||||
background: #f3f4f6 !important;
|
||||
border-color: #d1d5db !important;
|
||||
-webkit-text-fill-color: #111827 !important;
|
||||
}
|
||||
@media print {
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--md-text-color: #111827;
|
||||
--md-link-color: #1d4ed8;
|
||||
}
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 0;
|
||||
}
|
||||
html, body {
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
background: #ffffff !important;
|
||||
color: var(--md-text-color) !important;
|
||||
}
|
||||
body * {
|
||||
color: inherit !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
a {
|
||||
color: var(--md-link-color) !important;
|
||||
}
|
||||
code, pre {
|
||||
color: #111827 !important;
|
||||
}
|
||||
.content {
|
||||
max-width: none !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
h1, h2, h3 {
|
||||
break-after: avoid-page;
|
||||
}
|
||||
blockquote, figure {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
func escapedHTML(_ text: String) -> String {
|
||||
text
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
}
|
||||
}
|
||||
159
Neon Vision Editor/UI/ContentView+StartupOverlay.swift
Normal file
159
Neon Vision Editor/UI/ContentView+StartupOverlay.swift
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import SwiftUI
|
||||
|
||||
extension ContentView {
|
||||
var startupRecentFiles: [RecentFilesStore.Item] {
|
||||
_ = recentFilesRefreshToken
|
||||
return RecentFilesStore.items(limit: 5)
|
||||
}
|
||||
|
||||
var shouldShowStartupRecentFilesCard: Bool {
|
||||
guard !brainDumpLayoutEnabled else { return false }
|
||||
guard viewModel.tabs.count == 1 else { return false }
|
||||
guard let tab = viewModel.selectedTab else { return false }
|
||||
guard !tab.isLoadingContent else { return false }
|
||||
guard tab.fileURL == nil else { return false }
|
||||
guard tab.content.isEmpty else { return false }
|
||||
return !startupRecentFiles.isEmpty
|
||||
}
|
||||
|
||||
var shouldShowSafeModeStartupCard: Bool {
|
||||
guard startupBehavior == .safeMode else { return false }
|
||||
guard !brainDumpLayoutEnabled else { return false }
|
||||
guard viewModel.tabs.count == 1 else { return false }
|
||||
guard let tab = viewModel.selectedTab else { return false }
|
||||
guard !tab.isLoadingContent else { return false }
|
||||
return tab.fileURL == nil
|
||||
}
|
||||
|
||||
var shouldShowStartupOverlay: Bool {
|
||||
shouldShowSafeModeStartupCard || shouldShowStartupRecentFilesCard
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var startupOverlay: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if shouldShowSafeModeStartupCard {
|
||||
safeModeStartupCard
|
||||
}
|
||||
if shouldShowStartupRecentFilesCard {
|
||||
startupRecentFilesCard
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
|
||||
var safeModeStartupCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Label("Safe Mode", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.orange)
|
||||
|
||||
Text(safeModeMessage ?? "Safe Mode is active for this launch.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("Neon Vision Editor started with a blank document and skipped session restore plus startup diagnostics so you can recover safely.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if safeModeRecoveryPreparedForNextLaunch {
|
||||
Text("Normal startup will be used again on the next launch.")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button("Open File…") {
|
||||
openFileFromToolbar()
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Button("Normal Next Launch") {
|
||||
RuntimeReliabilityMonitor.shared.clearSafeModeRecoveryState()
|
||||
safeModeRecoveryPreparedForNextLaunch = true
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
#if os(macOS)
|
||||
Button("Settings…") {
|
||||
openSettings(tab: "general")
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: 560, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.fill(.regularMaterial)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.strokeBorder(Color.orange.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.08), radius: 16, x: 0, y: 6)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Safe Mode startup")
|
||||
}
|
||||
|
||||
var startupRecentFilesCard: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Recent Files")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(startupRecentFiles) { item in
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
_ = viewModel.openFile(url: item.url)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.title)
|
||||
.lineLimit(1)
|
||||
Text(item.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
RecentFilesStore.togglePinned(item.url)
|
||||
} label: {
|
||||
Image(systemName: item.isPinned ? "star.fill" : "star")
|
||||
.foregroundStyle(item.isPinned ? Color.yellow : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(item.isPinned ? "Unpin recent file" : "Pin recent file")
|
||||
.accessibilityHint("Keeps this file near the top of recent files")
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.thinMaterial)
|
||||
)
|
||||
}
|
||||
|
||||
Button("Open File…") {
|
||||
openFileFromToolbar()
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: 520)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.fill(.regularMaterial)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.08), radius: 16, x: 0, y: 6)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Recent files")
|
||||
}
|
||||
}
|
||||
|
|
@ -1108,6 +1108,17 @@ extension ContentView {
|
|||
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("Preview Style", systemImage: "textformat.size")
|
||||
.foregroundStyle(macToolbarSymbolColor)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import Foundation
|
|||
import Observation
|
||||
import UniformTypeIdentifiers
|
||||
import OSLog
|
||||
import Dispatch
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#elseif canImport(UIKit)
|
||||
|
|
@ -102,6 +103,7 @@ struct ContentView: View {
|
|||
enum StartupBehavior {
|
||||
case standard
|
||||
case forceBlankDocument
|
||||
case safeMode
|
||||
}
|
||||
|
||||
enum ProjectNavigatorPlacement: String, CaseIterable, Identifiable {
|
||||
|
|
@ -140,9 +142,11 @@ struct ContentView: View {
|
|||
}
|
||||
|
||||
let startupBehavior: StartupBehavior
|
||||
let safeModeMessage: String?
|
||||
|
||||
init(startupBehavior: StartupBehavior = .standard) {
|
||||
init(startupBehavior: StartupBehavior = .standard, safeModeMessage: String? = nil) {
|
||||
self.startupBehavior = startupBehavior
|
||||
self.safeModeMessage = safeModeMessage
|
||||
}
|
||||
|
||||
private enum EditorPerformanceThresholds {
|
||||
|
|
@ -219,8 +223,13 @@ struct ContentView: View {
|
|||
@AppStorage("SettingsConfirmCloseDirtyTab") var confirmCloseDirtyTab: Bool = true
|
||||
@AppStorage("SettingsConfirmClearEditor") var confirmClearEditor: Bool = true
|
||||
@AppStorage("SettingsActiveTab") var settingsActiveTab: String = "general"
|
||||
@AppStorage("SettingsAppearance") var appearance: String = "system"
|
||||
@AppStorage("SettingsTemplateLanguage") private var settingsTemplateLanguage: String = "swift"
|
||||
@AppStorage("SettingsThemeName") private var settingsThemeName: String = "Neon Glow"
|
||||
@AppStorage("SettingsThemeBoldKeywords") private var settingsThemeBoldKeywords: Bool = false
|
||||
@AppStorage("SettingsThemeItalicComments") private var settingsThemeItalicComments: Bool = false
|
||||
@AppStorage("SettingsThemeUnderlineLinks") private var settingsThemeUnderlineLinks: Bool = false
|
||||
@AppStorage("SettingsThemeBoldMarkdownHeadings") private var settingsThemeBoldMarkdownHeadings: Bool = false
|
||||
@State var lastProviderUsed: String = "Apple"
|
||||
@State private var highlightRefreshToken: Int = 0
|
||||
@State var editorExternalMutationRevision: Int = 0
|
||||
|
|
@ -286,11 +295,21 @@ struct ContentView: View {
|
|||
@State var iosExportDocument: PlainTextDocument = PlainTextDocument(text: "")
|
||||
@State var iosExportFilename: String = "Untitled.txt"
|
||||
@State var iosExportTabID: UUID? = nil
|
||||
@State var showMarkdownPDFExporter: Bool = false
|
||||
@State var markdownPDFExportDocument: PDFExportDocument = PDFExportDocument()
|
||||
@State var markdownPDFExportFilename: String = "Markdown-Preview.pdf"
|
||||
@State var markdownPDFExportErrorMessage: String?
|
||||
@State var showQuickSwitcher: Bool = false
|
||||
@State var quickSwitcherQuery: String = ""
|
||||
@State var quickSwitcherProjectFileURLs: [URL] = []
|
||||
@State var indexedProjectFileURLs: [URL] = []
|
||||
@State var isProjectFileIndexing: Bool = false
|
||||
@State var projectFileIndexRefreshGeneration: Int = 0
|
||||
@State var projectFileIndexTask: Task<Void, Never>? = nil
|
||||
@State var projectFolderMonitorSource: DispatchSourceFileSystemObject? = nil
|
||||
@State var pendingProjectFolderRefreshWorkItem: DispatchWorkItem? = nil
|
||||
@State private var quickSwitcherRecentItemIDs: [String] = []
|
||||
@State private var recentFilesRefreshToken: UUID = UUID()
|
||||
@State var recentFilesRefreshToken: UUID = UUID()
|
||||
@State private var currentSelectionSnapshotText: String = ""
|
||||
@State private var codeSnapshotPayload: CodeSnapshotPayload?
|
||||
@State var showFindInFiles: Bool = false
|
||||
|
|
@ -304,6 +323,7 @@ struct ContentView: View {
|
|||
@State private var wordCountTask: Task<Void, Never>?
|
||||
@State var vimModeEnabled: Bool = UserDefaults.standard.bool(forKey: "EditorVimModeEnabled")
|
||||
@State var vimInsertMode: Bool = true
|
||||
@State var safeModeRecoveryPreparedForNextLaunch: Bool = false
|
||||
@State var droppedFileLoadInProgress: Bool = false
|
||||
@State var droppedFileProgressDeterminate: Bool = true
|
||||
@State var droppedFileLoadProgress: Double = 0
|
||||
|
|
@ -345,6 +365,7 @@ struct ContentView: View {
|
|||
#elseif os(iOS)
|
||||
@AppStorage("MarkdownPreviewTemplateIOS") var markdownPreviewTemplateRaw: String = "default"
|
||||
#endif
|
||||
@AppStorage("MarkdownPreviewPDFExportMode") var markdownPDFExportModeRaw: String = "paginated-fit"
|
||||
@State private var showLanguageSetupPrompt: Bool = false
|
||||
@State private var languagePromptSelection: String = "plain"
|
||||
@State private var languagePromptInsertTemplate: Bool = false
|
||||
|
|
@ -418,9 +439,17 @@ struct ContentView: View {
|
|||
|
||||
var opacity: Double {
|
||||
switch self {
|
||||
case .subtle: return 0.98
|
||||
case .balanced: return 0.93
|
||||
case .vibrant: return 0.90
|
||||
case .subtle: return 0.84
|
||||
case .balanced: return 0.76
|
||||
case .vibrant: return 0.68
|
||||
}
|
||||
}
|
||||
|
||||
var toolbarOpacity: Double {
|
||||
switch self {
|
||||
case .subtle: return 0.72
|
||||
case .balanced: return 0.64
|
||||
case .vibrant: return 0.56
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -442,7 +471,7 @@ struct ContentView: View {
|
|||
|
||||
private var macToolbarBackgroundStyle: AnyShapeStyle {
|
||||
if enableTranslucentWindow {
|
||||
return AnyShapeStyle(macTranslucencyMode.material.opacity(0.8))
|
||||
return AnyShapeStyle(macTranslucencyMode.material.opacity(macTranslucencyMode.toolbarOpacity))
|
||||
}
|
||||
return AnyShapeStyle(Color(nsColor: .textBackgroundColor))
|
||||
}
|
||||
|
|
@ -1793,6 +1822,11 @@ struct ContentView: View {
|
|||
UserDefaults.standard.set(vimModeEnabled, forKey: "EditorVimModeEnabled")
|
||||
UserDefaults.standard.set(vimModeEnabled, forKey: "EditorVimInterceptionEnabled")
|
||||
vimInsertMode = !vimModeEnabled
|
||||
NotificationCenter.default.post(
|
||||
name: .vimModeStateDidChange,
|
||||
object: nil,
|
||||
userInfo: ["insertMode": vimInsertMode]
|
||||
)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .toggleSidebarRequested)) { notif in
|
||||
guard matchesCurrentWindow(notif) else { return }
|
||||
|
|
@ -2024,6 +2058,17 @@ struct ContentView: View {
|
|||
} message: {
|
||||
Text(whitespaceInspectorMessage ?? "")
|
||||
}
|
||||
.alert(
|
||||
"PDF Export Failed",
|
||||
isPresented: Binding(
|
||||
get: { markdownPDFExportErrorMessage != nil },
|
||||
set: { if !$0 { markdownPDFExportErrorMessage = nil } }
|
||||
)
|
||||
) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text(markdownPDFExportErrorMessage ?? "")
|
||||
}
|
||||
.navigationTitle("Neon Vision Editor")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
|
@ -2044,7 +2089,7 @@ struct ContentView: View {
|
|||
#endif
|
||||
}
|
||||
|
||||
private var lifecycleConfiguredRootView: some View {
|
||||
private var rootViewWithStateObservers: some View {
|
||||
basePlatformRootView
|
||||
.onAppear {
|
||||
handleSettingsAndEditorDefaultsOnAppear()
|
||||
|
|
@ -2075,6 +2120,9 @@ struct ContentView: View {
|
|||
.onChange(of: settingsThemeName) { _, _ in
|
||||
scheduleHighlightRefresh()
|
||||
}
|
||||
.onChange(of: themeFormattingRefreshSignature) { _, _ in
|
||||
scheduleHighlightRefresh()
|
||||
}
|
||||
.onChange(of: highlightMatchingBrackets) { _, _ in
|
||||
scheduleHighlightRefresh()
|
||||
}
|
||||
|
|
@ -2098,32 +2146,34 @@ struct ContentView: View {
|
|||
persistSessionIfReady()
|
||||
}
|
||||
.onChange(of: showSupportedProjectFilesOnly) { _, _ in
|
||||
refreshProjectTree()
|
||||
refreshProjectBrowserState()
|
||||
}
|
||||
.onChange(of: showMarkdownPreviewPane) { _, _ in
|
||||
persistSessionIfReady()
|
||||
}
|
||||
}
|
||||
|
||||
private var rootViewWithPlatformLifecycleObservers: some View {
|
||||
rootViewWithStateObservers
|
||||
#if os(iOS)
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
if let selectedID = viewModel.selectedTab?.id {
|
||||
viewModel.refreshExternalConflictForTab(tabID: selectedID)
|
||||
}
|
||||
handleAppDidBecomeActive()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||
if let selectedID = viewModel.selectedTab?.id {
|
||||
viewModel.refreshExternalConflictForTab(tabID: selectedID)
|
||||
}
|
||||
handleAppDidBecomeActive()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification)) { _ in
|
||||
persistSessionIfReady()
|
||||
persistUnsavedDraftSnapshotIfNeeded()
|
||||
handleAppWillResignActive()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
||||
persistSessionIfReady()
|
||||
persistUnsavedDraftSnapshotIfNeeded()
|
||||
handleAppWillResignActive()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var lifecycleConfiguredRootView: some View {
|
||||
rootViewWithPlatformLifecycleObservers
|
||||
.onOpenURL { url in
|
||||
viewModel.openFile(url: url)
|
||||
}
|
||||
|
|
@ -2316,7 +2366,7 @@ struct ContentView: View {
|
|||
onOpenFolder: { contentView.openProjectFolder() },
|
||||
onToggleSupportedFilesOnly: { contentView.showSupportedProjectFilesOnly = $0 },
|
||||
onOpenProjectFile: { contentView.openProjectFile(url: $0) },
|
||||
onRefreshTree: { contentView.refreshProjectTree() }
|
||||
onRefreshTree: { contentView.refreshProjectBrowserState() }
|
||||
)
|
||||
.navigationTitle("Project Structure")
|
||||
.toolbar {
|
||||
|
|
@ -2553,6 +2603,16 @@ struct ContentView: View {
|
|||
) { result in
|
||||
contentView.handleIOSExportResult(result)
|
||||
}
|
||||
.fileExporter(
|
||||
isPresented: contentView.$showMarkdownPDFExporter,
|
||||
document: contentView.markdownPDFExportDocument,
|
||||
contentType: .pdf,
|
||||
defaultFilename: contentView.markdownPDFExportFilename
|
||||
) { result in
|
||||
if case .failure(let error) = result {
|
||||
contentView.markdownPDFExportErrorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
@ -2566,6 +2626,15 @@ struct ContentView: View {
|
|||
#endif
|
||||
}
|
||||
|
||||
private var themeFormattingRefreshSignature: Int {
|
||||
var signature = 0
|
||||
if settingsThemeBoldKeywords { signature |= 1 << 0 }
|
||||
if settingsThemeItalicComments { signature |= 1 << 1 }
|
||||
if settingsThemeUnderlineLinks { signature |= 1 << 2 }
|
||||
if settingsThemeBoldMarkdownHeadings { signature |= 1 << 3 }
|
||||
return signature
|
||||
}
|
||||
|
||||
private var effectiveIndentWidth: Int {
|
||||
projectOverrideIndentWidth ?? indentWidth
|
||||
}
|
||||
|
|
@ -2577,15 +2646,22 @@ struct ContentView: View {
|
|||
private func applyStartupBehaviorIfNeeded() {
|
||||
guard !didApplyStartupBehavior else { return }
|
||||
|
||||
if startupBehavior == .forceBlankDocument {
|
||||
if startupBehavior == .forceBlankDocument || startupBehavior == .safeMode {
|
||||
viewModel.resetTabsForSessionRestore()
|
||||
viewModel.addNewTab()
|
||||
projectRootFolderURL = nil
|
||||
clearProjectEditorOverrides()
|
||||
projectTreeNodes = []
|
||||
quickSwitcherProjectFileURLs = []
|
||||
stopProjectFolderObservation()
|
||||
indexedProjectFileURLs = []
|
||||
isProjectFileIndexing = false
|
||||
projectFileIndexTask?.cancel()
|
||||
projectFileIndexTask = nil
|
||||
didApplyStartupBehavior = true
|
||||
if startupBehavior != .safeMode {
|
||||
persistSessionIfReady()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -2595,12 +2671,6 @@ struct ContentView: View {
|
|||
return
|
||||
}
|
||||
|
||||
if restoreUnsavedDraftSnapshotIfAvailable() {
|
||||
didApplyStartupBehavior = true
|
||||
persistSessionIfReady()
|
||||
return
|
||||
}
|
||||
|
||||
// If both startup toggles are enabled (legacy/default mismatch), prefer session restore.
|
||||
let shouldOpenBlankOnStartup = openWithBlankDocument && !reopenLastSession
|
||||
if shouldOpenBlankOnStartup {
|
||||
|
|
@ -2610,11 +2680,18 @@ struct ContentView: View {
|
|||
clearProjectEditorOverrides()
|
||||
projectTreeNodes = []
|
||||
quickSwitcherProjectFileURLs = []
|
||||
stopProjectFolderObservation()
|
||||
indexedProjectFileURLs = []
|
||||
isProjectFileIndexing = false
|
||||
projectFileIndexTask?.cancel()
|
||||
projectFileIndexTask = nil
|
||||
didApplyStartupBehavior = true
|
||||
persistSessionIfReady()
|
||||
return
|
||||
}
|
||||
|
||||
var restoredSessionTabs = false
|
||||
|
||||
// Restore last session first when enabled.
|
||||
if reopenLastSession {
|
||||
if projectRootFolderURL == nil, let restoredProjectFolderURL = restoredLastSessionProjectFolderURL() {
|
||||
|
|
@ -2634,12 +2711,20 @@ struct ContentView: View {
|
|||
_ = viewModel.focusTabIfOpen(for: selectedURL)
|
||||
}
|
||||
|
||||
restoredSessionTabs = !viewModel.tabs.isEmpty
|
||||
if viewModel.tabs.isEmpty {
|
||||
viewModel.addNewTab()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore unsaved drafts only as fallback when no file session tabs were restored.
|
||||
if !restoredSessionTabs, restoreUnsavedDraftSnapshotIfAvailable() {
|
||||
didApplyStartupBehavior = true
|
||||
persistSessionIfReady()
|
||||
return
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
// Keep mobile layout in a valid tab state so the file tab bar always has content.
|
||||
if viewModel.tabs.isEmpty {
|
||||
|
|
@ -2653,8 +2738,9 @@ struct ContentView: View {
|
|||
persistSessionIfReady()
|
||||
}
|
||||
|
||||
private func persistSessionIfReady() {
|
||||
func persistSessionIfReady() {
|
||||
guard didApplyStartupBehavior else { return }
|
||||
guard startupBehavior != .safeMode else { return }
|
||||
let fileURLs = viewModel.tabs.compactMap { $0.fileURL }
|
||||
UserDefaults.standard.set(fileURLs.map(\.absoluteString), forKey: "LastSessionFileURLs")
|
||||
UserDefaults.standard.set(viewModel.selectedTab?.fileURL?.absoluteString, forKey: "LastSessionSelectedFileURL")
|
||||
|
|
@ -3173,7 +3259,7 @@ struct ContentView: View {
|
|||
return currentContent
|
||||
}
|
||||
|
||||
private var brainDumpLayoutEnabled: Bool {
|
||||
var brainDumpLayoutEnabled: Bool {
|
||||
#if os(macOS)
|
||||
return viewModel.isBrainDumpMode
|
||||
#else
|
||||
|
|
@ -3722,10 +3808,24 @@ struct ContentView: View {
|
|||
onOpenFolder: { openProjectFolder() },
|
||||
onToggleSupportedFilesOnly: { showSupportedProjectFilesOnly = $0 },
|
||||
onOpenProjectFile: { openProjectFile(url: $0) },
|
||||
onRefreshTree: { refreshProjectTree() }
|
||||
onRefreshTree: { refreshProjectBrowserState() }
|
||||
)
|
||||
}
|
||||
|
||||
private func handleAppDidBecomeActive() {
|
||||
if let selectedID = viewModel.selectedTab?.id {
|
||||
viewModel.refreshExternalConflictForTab(tabID: selectedID)
|
||||
}
|
||||
if projectRootFolderURL != nil {
|
||||
refreshProjectBrowserState()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAppWillResignActive() {
|
||||
persistSessionIfReady()
|
||||
persistUnsavedDraftSnapshotIfNeeded()
|
||||
}
|
||||
|
||||
private var delimitedModeControl: some View {
|
||||
HStack(spacing: 10) {
|
||||
Picker("CSV/TSV View Mode", selection: $delimitedViewMode) {
|
||||
|
|
@ -4046,8 +4146,8 @@ struct ContentView: View {
|
|||
)
|
||||
.id(currentLanguage)
|
||||
.overlay {
|
||||
if shouldShowStartupRecentFilesCard {
|
||||
startupRecentFilesCard
|
||||
if shouldShowStartupOverlay {
|
||||
startupOverlay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4208,6 +4308,7 @@ struct ContentView: View {
|
|||
#if os(macOS)
|
||||
.onChange(of: macTranslucencyModeRaw) { _, _ in
|
||||
// Keep all chrome/background surfaces in lockstep when mode changes.
|
||||
applyWindowTranslucency(enableTranslucentWindow)
|
||||
highlightRefreshToken &+= 1
|
||||
}
|
||||
#endif
|
||||
|
|
@ -4298,468 +4399,54 @@ struct ContentView: View {
|
|||
Text("Docs").tag("docs")
|
||||
Text("Article").tag("article")
|
||||
Text("Compact").tag("compact")
|
||||
Text("GitHub Docs").tag("github-docs")
|
||||
Text("Academic Paper").tag("academic-paper")
|
||||
Text("Terminal Notes").tag("terminal-notes")
|
||||
Text("Magazine").tag("magazine")
|
||||
Text("Minimal Reader").tag("minimal-reader")
|
||||
Text("Presentation").tag("presentation")
|
||||
Text("Night Contrast").tag("night-contrast")
|
||||
Text("Warm Sepia").tag("warm-sepia")
|
||||
Text("Dense Compact").tag("dense-compact")
|
||||
Text("Developer Spec").tag("developer-spec")
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 120)
|
||||
.frame(minWidth: 120, idealWidth: 190, maxWidth: 220)
|
||||
Picker("PDF Mode", selection: $markdownPDFExportModeRaw) {
|
||||
Text("Paginated Fit").tag(MarkdownPDFExportMode.paginatedFit.rawValue)
|
||||
Text("One Page Fit").tag(MarkdownPDFExportMode.onePageFit.rawValue)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
.frame(minWidth: 128, idealWidth: 160, maxWidth: 180)
|
||||
Button {
|
||||
exportMarkdownPreviewPDF()
|
||||
} label: {
|
||||
Label("Export PDF", systemImage: "doc.badge.arrow.down")
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(NeonUIStyle.accentBlue)
|
||||
.controlSize(.regular)
|
||||
.layoutPriority(1)
|
||||
.accessibilityLabel("Export Markdown preview as PDF")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(editorSurfaceBackgroundStyle)
|
||||
|
||||
MarkdownPreviewWebView(html: markdownPreviewHTML(from: currentContent))
|
||||
MarkdownPreviewWebView(
|
||||
html: markdownPreviewHTML(
|
||||
from: currentContent,
|
||||
preferDarkMode: markdownPreviewPreferDarkMode
|
||||
)
|
||||
)
|
||||
.accessibilityLabel("Markdown Preview Content")
|
||||
}
|
||||
.background(editorSurfaceBackgroundStyle)
|
||||
}
|
||||
|
||||
private var markdownPreviewTemplate: String {
|
||||
switch markdownPreviewTemplateRaw {
|
||||
case "docs", "article", "compact":
|
||||
return markdownPreviewTemplateRaw
|
||||
default:
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
private var markdownPreviewRenderByteLimit: Int { 180_000 }
|
||||
private var markdownPreviewFallbackCharacterLimit: Int { 120_000 }
|
||||
|
||||
private func markdownPreviewHTML(from markdownText: String) -> String {
|
||||
let bodyHTML = markdownPreviewBodyHTML(from: markdownText)
|
||||
return """
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
\(markdownPreviewCSS(template: markdownPreviewTemplate))
|
||||
</style>
|
||||
</head>
|
||||
<body class="\(markdownPreviewTemplate)">
|
||||
<main class="content">
|
||||
\(bodyHTML)
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
}
|
||||
|
||||
private func markdownPreviewBodyHTML(from markdownText: String) -> String {
|
||||
let byteCount = markdownText.lengthOfBytes(using: .utf8)
|
||||
if byteCount > markdownPreviewRenderByteLimit {
|
||||
return largeMarkdownFallbackHTML(from: markdownText, byteCount: byteCount)
|
||||
}
|
||||
return renderedMarkdownBodyHTML(from: markdownText) ?? "<pre>\(escapedHTML(markdownText))</pre>"
|
||||
}
|
||||
|
||||
private func largeMarkdownFallbackHTML(from markdownText: String, byteCount: Int) -> String {
|
||||
let previewText = String(markdownText.prefix(markdownPreviewFallbackCharacterLimit))
|
||||
let truncated = previewText.count < markdownText.count
|
||||
let statusSuffix = truncated ? " (truncated preview)" : ""
|
||||
return """
|
||||
<section class="preview-warning">
|
||||
<p><strong>Large Markdown file</strong></p>
|
||||
<p class="preview-warning-meta">Rendering full Markdown is skipped for stability (\(byteCount) bytes)\(statusSuffix).</p>
|
||||
</section>
|
||||
<pre>\(escapedHTML(previewText))</pre>
|
||||
"""
|
||||
}
|
||||
|
||||
private func renderedMarkdownBodyHTML(from markdownText: String) -> String? {
|
||||
let html = simpleMarkdownToHTML(markdownText).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return html.isEmpty ? nil : html
|
||||
}
|
||||
|
||||
private func simpleMarkdownToHTML(_ markdown: String) -> String {
|
||||
let lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")
|
||||
var result: [String] = []
|
||||
var paragraphLines: [String] = []
|
||||
var insideCodeFence = false
|
||||
var codeFenceLanguage: String?
|
||||
var insideUnorderedList = false
|
||||
var insideOrderedList = false
|
||||
var insideBlockquote = false
|
||||
|
||||
func flushParagraph() {
|
||||
guard !paragraphLines.isEmpty else { return }
|
||||
let paragraph = paragraphLines.map { inlineMarkdownToHTML($0) }.joined(separator: "<br/>")
|
||||
result.append("<p>\(paragraph)</p>")
|
||||
paragraphLines.removeAll(keepingCapacity: true)
|
||||
}
|
||||
|
||||
func closeLists() {
|
||||
if insideUnorderedList {
|
||||
result.append("</ul>")
|
||||
insideUnorderedList = false
|
||||
}
|
||||
if insideOrderedList {
|
||||
result.append("</ol>")
|
||||
insideOrderedList = false
|
||||
}
|
||||
}
|
||||
|
||||
func closeBlockquote() {
|
||||
if insideBlockquote {
|
||||
flushParagraph()
|
||||
closeLists()
|
||||
result.append("</blockquote>")
|
||||
insideBlockquote = false
|
||||
}
|
||||
}
|
||||
|
||||
func closeParagraphAndInlineContainers() {
|
||||
flushParagraph()
|
||||
closeLists()
|
||||
}
|
||||
|
||||
for rawLine in lines {
|
||||
let line = rawLine
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
if trimmed.hasPrefix("```") {
|
||||
if insideCodeFence {
|
||||
result.append("</code></pre>")
|
||||
insideCodeFence = false
|
||||
codeFenceLanguage = nil
|
||||
} else {
|
||||
closeBlockquote()
|
||||
closeParagraphAndInlineContainers()
|
||||
insideCodeFence = true
|
||||
let lang = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces)
|
||||
codeFenceLanguage = lang.isEmpty ? nil : lang
|
||||
if let codeFenceLanguage {
|
||||
result.append("<pre><code class=\"language-\(escapedHTML(codeFenceLanguage))\">")
|
||||
} else {
|
||||
result.append("<pre><code>")
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if insideCodeFence {
|
||||
result.append("\(escapedHTML(line))\n")
|
||||
continue
|
||||
}
|
||||
|
||||
if trimmed.isEmpty {
|
||||
closeParagraphAndInlineContainers()
|
||||
closeBlockquote()
|
||||
continue
|
||||
}
|
||||
|
||||
if let heading = markdownHeading(from: trimmed) {
|
||||
closeBlockquote()
|
||||
closeParagraphAndInlineContainers()
|
||||
result.append("<h\(heading.level)>\(inlineMarkdownToHTML(heading.text))</h\(heading.level)>")
|
||||
continue
|
||||
}
|
||||
|
||||
if isMarkdownHorizontalRule(trimmed) {
|
||||
closeBlockquote()
|
||||
closeParagraphAndInlineContainers()
|
||||
result.append("<hr/>")
|
||||
continue
|
||||
}
|
||||
|
||||
var workingLine = trimmed
|
||||
let isBlockquoteLine = workingLine.hasPrefix(">")
|
||||
if isBlockquoteLine {
|
||||
if !insideBlockquote {
|
||||
closeParagraphAndInlineContainers()
|
||||
result.append("<blockquote>")
|
||||
insideBlockquote = true
|
||||
}
|
||||
workingLine = workingLine.dropFirst().trimmingCharacters(in: .whitespaces)
|
||||
} else {
|
||||
closeBlockquote()
|
||||
}
|
||||
|
||||
if let unordered = markdownUnorderedListItem(from: workingLine) {
|
||||
flushParagraph()
|
||||
if insideOrderedList {
|
||||
result.append("</ol>")
|
||||
insideOrderedList = false
|
||||
}
|
||||
if !insideUnorderedList {
|
||||
result.append("<ul>")
|
||||
insideUnorderedList = true
|
||||
}
|
||||
result.append("<li>\(inlineMarkdownToHTML(unordered))</li>")
|
||||
continue
|
||||
}
|
||||
|
||||
if let ordered = markdownOrderedListItem(from: workingLine) {
|
||||
flushParagraph()
|
||||
if insideUnorderedList {
|
||||
result.append("</ul>")
|
||||
insideUnorderedList = false
|
||||
}
|
||||
if !insideOrderedList {
|
||||
result.append("<ol>")
|
||||
insideOrderedList = true
|
||||
}
|
||||
result.append("<li>\(inlineMarkdownToHTML(ordered))</li>")
|
||||
continue
|
||||
}
|
||||
|
||||
closeLists()
|
||||
paragraphLines.append(workingLine)
|
||||
}
|
||||
|
||||
closeBlockquote()
|
||||
closeParagraphAndInlineContainers()
|
||||
if insideCodeFence {
|
||||
result.append("</code></pre>")
|
||||
}
|
||||
return result.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func markdownHeading(from line: String) -> (level: Int, text: String)? {
|
||||
let pattern = "^(#{1,6})\\s+(.+)$"
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let range = NSRange(line.startIndex..., in: line)
|
||||
guard let match = regex.firstMatch(in: line, options: [], range: range),
|
||||
let hashesRange = Range(match.range(at: 1), in: line),
|
||||
let textRange = Range(match.range(at: 2), in: line) else {
|
||||
return nil
|
||||
}
|
||||
return (line[hashesRange].count, String(line[textRange]))
|
||||
}
|
||||
|
||||
private func isMarkdownHorizontalRule(_ line: String) -> Bool {
|
||||
let compact = line.replacingOccurrences(of: " ", with: "")
|
||||
return compact == "***" || compact == "---" || compact == "___"
|
||||
}
|
||||
|
||||
private func markdownUnorderedListItem(from line: String) -> String? {
|
||||
let pattern = "^[-*+]\\s+(.+)$"
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let range = NSRange(line.startIndex..., in: line)
|
||||
guard let match = regex.firstMatch(in: line, options: [], range: range),
|
||||
let textRange = Range(match.range(at: 1), in: line) else {
|
||||
return nil
|
||||
}
|
||||
return String(line[textRange])
|
||||
}
|
||||
|
||||
private func markdownOrderedListItem(from line: String) -> String? {
|
||||
let pattern = "^\\d+[\\.)]\\s+(.+)$"
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let range = NSRange(line.startIndex..., in: line)
|
||||
guard let match = regex.firstMatch(in: line, options: [], range: range),
|
||||
let textRange = Range(match.range(at: 1), in: line) else {
|
||||
return nil
|
||||
}
|
||||
return String(line[textRange])
|
||||
}
|
||||
|
||||
private func inlineMarkdownToHTML(_ text: String) -> String {
|
||||
var html = escapedHTML(text)
|
||||
var codeSpans: [String] = []
|
||||
|
||||
html = replacingRegex(in: html, pattern: "`([^`]+)`") { match in
|
||||
let content = String(match.dropFirst().dropLast())
|
||||
let token = "__CODE_SPAN_\(codeSpans.count)__"
|
||||
codeSpans.append("<code>\(content)</code>")
|
||||
return token
|
||||
}
|
||||
|
||||
html = replacingRegex(in: html, pattern: "!\\[([^\\]]*)\\]\\(([^\\)\\s]+)\\)") { match in
|
||||
let parts = captureGroups(in: match, pattern: "!\\[([^\\]]*)\\]\\(([^\\)\\s]+)\\)")
|
||||
guard parts.count == 2 else { return match }
|
||||
return "<img src=\"\(parts[1])\" alt=\"\(parts[0])\"/>"
|
||||
}
|
||||
|
||||
html = replacingRegex(in: html, pattern: "\\[([^\\]]+)\\]\\(([^\\)\\s]+)\\)") { match in
|
||||
let parts = captureGroups(in: match, pattern: "\\[([^\\]]+)\\]\\(([^\\)\\s]+)\\)")
|
||||
guard parts.count == 2 else { return match }
|
||||
return "<a href=\"\(parts[1])\">\(parts[0])</a>"
|
||||
}
|
||||
|
||||
html = replacingRegex(in: html, pattern: "\\*\\*([^*]+)\\*\\*") { "<strong>\(String($0.dropFirst(2).dropLast(2)))</strong>" }
|
||||
html = replacingRegex(in: html, pattern: "__([^_]+)__") { "<strong>\(String($0.dropFirst(2).dropLast(2)))</strong>" }
|
||||
html = replacingRegex(in: html, pattern: "\\*([^*]+)\\*") { "<em>\(String($0.dropFirst().dropLast()))</em>" }
|
||||
html = replacingRegex(in: html, pattern: "_([^_]+)_") { "<em>\(String($0.dropFirst().dropLast()))</em>" }
|
||||
|
||||
for (index, codeHTML) in codeSpans.enumerated() {
|
||||
html = html.replacingOccurrences(of: "__CODE_SPAN_\(index)__", with: codeHTML)
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
private func replacingRegex(in text: String, pattern: String, transform: (String) -> String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
|
||||
let matches = regex.matches(in: text, options: [], range: NSRange(text.startIndex..., in: text))
|
||||
guard !matches.isEmpty else { return text }
|
||||
|
||||
var output = text
|
||||
for match in matches.reversed() {
|
||||
guard let range = Range(match.range, in: output) else { continue }
|
||||
let segment = String(output[range])
|
||||
output.replaceSubrange(range, with: transform(segment))
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private func captureGroups(in text: String, pattern: String) -> [String] {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern),
|
||||
let match = regex.firstMatch(in: text, options: [], range: NSRange(text.startIndex..., in: text)) else {
|
||||
return []
|
||||
}
|
||||
var groups: [String] = []
|
||||
for idx in 1..<match.numberOfRanges {
|
||||
if let range = Range(match.range(at: idx), in: text) {
|
||||
groups.append(String(text[range]))
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
private func markdownPreviewCSS(template: String) -> String {
|
||||
let basePadding: String
|
||||
let fontSize: String
|
||||
let lineHeight: String
|
||||
let maxWidth: String
|
||||
switch template {
|
||||
case "docs":
|
||||
basePadding = "22px 30px"
|
||||
fontSize = "15px"
|
||||
lineHeight = "1.7"
|
||||
maxWidth = "900px"
|
||||
case "article":
|
||||
basePadding = "32px 48px"
|
||||
fontSize = "17px"
|
||||
lineHeight = "1.8"
|
||||
maxWidth = "760px"
|
||||
case "compact":
|
||||
basePadding = "14px 16px"
|
||||
fontSize = "13px"
|
||||
lineHeight = "1.5"
|
||||
maxWidth = "none"
|
||||
default:
|
||||
basePadding = "18px 22px"
|
||||
fontSize = "14px"
|
||||
lineHeight = "1.6"
|
||||
maxWidth = "860px"
|
||||
}
|
||||
|
||||
return """
|
||||
:root { color-scheme: light dark; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
|
||||
font-size: \(fontSize);
|
||||
line-height: \(lineHeight);
|
||||
}
|
||||
.content {
|
||||
max-width: \(maxWidth);
|
||||
padding: \(basePadding);
|
||||
margin: 0 auto;
|
||||
}
|
||||
.preview-warning {
|
||||
margin: 0.5em 0 0.8em;
|
||||
padding: 0.75em 0.9em;
|
||||
border-radius: 9px;
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 45%, transparent);
|
||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
}
|
||||
.preview-warning p {
|
||||
margin: 0;
|
||||
}
|
||||
.preview-warning-meta {
|
||||
margin-top: 0.4em !important;
|
||||
font-size: 0.92em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1.25;
|
||||
margin: 1.1em 0 0.55em;
|
||||
font-weight: 700;
|
||||
}
|
||||
h1 { font-size: 1.85em; border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent); padding-bottom: 0.25em; }
|
||||
h2 { font-size: 1.45em; border-bottom: 1px solid color-mix(in srgb, currentColor 13%, transparent); padding-bottom: 0.2em; }
|
||||
h3 { font-size: 1.2em; }
|
||||
p, ul, ol, blockquote, table, pre { margin: 0.65em 0; }
|
||||
ul, ol { padding-left: 1.3em; }
|
||||
li { margin: 0.2em 0; }
|
||||
blockquote {
|
||||
margin-left: 0;
|
||||
padding: 0.45em 0.9em;
|
||||
border-left: 3px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||
border-radius: 6px;
|
||||
}
|
||||
code {
|
||||
font-family: "SF Mono", "Menlo", "Monaco", monospace;
|
||||
font-size: 0.9em;
|
||||
padding: 0.12em 0.35em;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
}
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
padding: 0.8em 0.95em;
|
||||
border-radius: 9px;
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
|
||||
line-height: 1.35;
|
||||
white-space: pre;
|
||||
}
|
||||
pre code {
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
font-size: 0.88em;
|
||||
line-height: 1.35;
|
||||
white-space: pre;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.45em 0.55em;
|
||||
border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent);
|
||||
}
|
||||
th {
|
||||
background: color-mix(in srgb, currentColor 7%, transparent);
|
||||
font-weight: 600;
|
||||
}
|
||||
a {
|
||||
color: #2f7cf6;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid color-mix(in srgb, #2f7cf6 45%, transparent);
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent);
|
||||
margin: 1.1em 0;
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
private func escapedHTML(_ text: String) -> String {
|
||||
text
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
|
|
@ -5074,7 +4761,11 @@ struct ContentView: View {
|
|||
)
|
||||
}
|
||||
|
||||
for url in quickSwitcherProjectFileURLs {
|
||||
let projectQuickSwitcherFileURLs = indexedProjectFileURLs.isEmpty
|
||||
? quickSwitcherProjectFileURLs
|
||||
: indexedProjectFileURLs
|
||||
|
||||
for url in projectQuickSwitcherFileURLs {
|
||||
let standardized = url.standardizedFileURL.path
|
||||
if fileURLSet.contains(standardized) { continue }
|
||||
if items.contains(where: { $0.id == "file:\(standardized)" }) { continue }
|
||||
|
|
@ -5195,82 +4886,6 @@ struct ContentView: View {
|
|||
UserDefaults.standard.set(quickSwitcherRecentItemIDs, forKey: quickSwitcherRecentsDefaultsKey)
|
||||
}
|
||||
|
||||
private var startupRecentFiles: [RecentFilesStore.Item] {
|
||||
_ = recentFilesRefreshToken
|
||||
return RecentFilesStore.items(limit: 5)
|
||||
}
|
||||
|
||||
private var shouldShowStartupRecentFilesCard: Bool {
|
||||
guard !brainDumpLayoutEnabled else { return false }
|
||||
guard viewModel.tabs.count == 1 else { return false }
|
||||
guard let tab = viewModel.selectedTab else { return false }
|
||||
guard !tab.isLoadingContent else { return false }
|
||||
guard tab.fileURL == nil else { return false }
|
||||
guard tab.content.isEmpty else { return false }
|
||||
return !startupRecentFiles.isEmpty
|
||||
}
|
||||
|
||||
private var startupRecentFilesCard: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Recent Files")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(startupRecentFiles) { item in
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
_ = viewModel.openFile(url: item.url)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.title)
|
||||
.lineLimit(1)
|
||||
Text(item.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
RecentFilesStore.togglePinned(item.url)
|
||||
} label: {
|
||||
Image(systemName: item.isPinned ? "star.fill" : "star")
|
||||
.foregroundStyle(item.isPinned ? Color.yellow : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(item.isPinned ? "Unpin recent file" : "Pin recent file")
|
||||
.accessibilityHint("Keeps this file near the top of recent files")
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.thinMaterial)
|
||||
)
|
||||
}
|
||||
|
||||
Button("Open File…") {
|
||||
openFileFromToolbar()
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: 520)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.fill(.regularMaterial)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.08), radius: 16, x: 0, y: 6)
|
||||
.padding(24)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Recent files")
|
||||
}
|
||||
|
||||
private func quickSwitcherRecencyScore(for itemID: String) -> Int {
|
||||
guard let index = quickSwitcherRecentItemIDs.firstIndex(of: itemID) else { return 0 }
|
||||
return max(0, 120 - (index * 5))
|
||||
|
|
@ -5330,12 +4945,18 @@ struct ContentView: View {
|
|||
}
|
||||
|
||||
findInFilesTask?.cancel()
|
||||
let candidateFiles = indexedProjectFileURLs.isEmpty ? nil : indexedProjectFileURLs
|
||||
if candidateFiles == nil, isProjectFileIndexing {
|
||||
findInFilesStatusMessage = "Searching while project index updates…"
|
||||
} else {
|
||||
findInFilesStatusMessage = "Searching…"
|
||||
}
|
||||
|
||||
let caseSensitive = findInFilesCaseSensitive
|
||||
findInFilesTask = Task {
|
||||
let results = await ContentView.findInFiles(
|
||||
root: root,
|
||||
candidateFiles: candidateFiles,
|
||||
query: query,
|
||||
caseSensitive: caseSensitive,
|
||||
maxResults: 500
|
||||
|
|
|
|||
|
|
@ -73,6 +73,29 @@ private func syntaxProfile(for language: String, text: NSString) -> SyntaxPatter
|
|||
return .full
|
||||
}
|
||||
|
||||
private enum SyntaxFontEmphasis {
|
||||
case keyword
|
||||
case comment
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func fontWithSymbolicTrait(_ font: NSFont, trait: NSFontDescriptor.SymbolicTraits) -> NSFont {
|
||||
let descriptor = font.fontDescriptor.withSymbolicTraits(font.fontDescriptor.symbolicTraits.union(trait))
|
||||
guard
|
||||
let adjustedFont = NSFont(descriptor: descriptor, size: font.pointSize) else {
|
||||
return font
|
||||
}
|
||||
return adjustedFont
|
||||
}
|
||||
#else
|
||||
private func fontWithSymbolicTrait(_ font: UIFont, trait: UIFontDescriptor.SymbolicTraits) -> UIFont {
|
||||
guard let descriptor = font.fontDescriptor.withSymbolicTraits(font.fontDescriptor.symbolicTraits.union(trait)) else {
|
||||
return font
|
||||
}
|
||||
return UIFont(descriptor: descriptor, size: font.pointSize)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func supportsResponsiveLargeFileHighlight(language: String) -> Bool {
|
||||
isJSONLikeLanguage(language) &&
|
||||
currentLargeFileSyntaxHighlightMode() == .minimal &&
|
||||
|
|
@ -2921,6 +2944,7 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
explicitRange: targetRange,
|
||||
immediate: immediate
|
||||
)
|
||||
let emphasisPatterns = syntaxEmphasisPatterns(for: language, profile: syntaxProfile)
|
||||
|
||||
// Cancel any in-flight work
|
||||
pendingHighlight?.cancel()
|
||||
|
|
@ -2929,6 +2953,7 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
let interval = syntaxHighlightSignposter.beginInterval("rehighlight_macos")
|
||||
// Compute matches off the main thread
|
||||
var coloredRanges: [(NSRange, Color)] = []
|
||||
var emphasizedRanges: [(NSRange, SyntaxFontEmphasis)] = []
|
||||
if let fastRanges = fastSyntaxColorRanges(
|
||||
language: language,
|
||||
profile: syntaxProfile,
|
||||
|
|
@ -2947,6 +2972,25 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
}
|
||||
}
|
||||
|
||||
if theme.boldKeywords {
|
||||
for pattern in emphasisPatterns.keyword {
|
||||
guard let regex = cachedSyntaxRegex(pattern: pattern, options: [.anchorsMatchLines]) else { continue }
|
||||
let matches = regex.matches(in: textSnapshot, range: applyRange)
|
||||
for match in matches {
|
||||
emphasizedRanges.append((match.range, .keyword))
|
||||
}
|
||||
}
|
||||
}
|
||||
if theme.italicComments {
|
||||
for pattern in emphasisPatterns.comment {
|
||||
guard let regex = cachedSyntaxRegex(pattern: pattern, options: [.anchorsMatchLines]) else { continue }
|
||||
let matches = regex.matches(in: textSnapshot, range: applyRange)
|
||||
for match in matches {
|
||||
emphasizedRanges.append((match.range, .comment))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self, let tv = self.textView else {
|
||||
syntaxHighlightSignposter.endInterval("rehighlight_macos", interval)
|
||||
|
|
@ -2979,11 +3023,26 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
tv.textStorage?.removeAttribute(.foregroundColor, range: applyRange)
|
||||
tv.textStorage?.removeAttribute(.backgroundColor, range: applyRange)
|
||||
tv.textStorage?.removeAttribute(.underlineStyle, range: applyRange)
|
||||
tv.textStorage?.removeAttribute(.font, range: applyRange)
|
||||
tv.textStorage?.addAttribute(.foregroundColor, value: baseColor, range: applyRange)
|
||||
let baseFont = self.parent.resolvedFont()
|
||||
tv.textStorage?.addAttribute(.font, value: baseFont, range: applyRange)
|
||||
let boldKeywordFont = fontWithSymbolicTrait(baseFont, trait: .bold)
|
||||
let italicCommentFont = fontWithSymbolicTrait(baseFont, trait: .italic)
|
||||
// Apply colored ranges
|
||||
for (range, color) in coloredRanges {
|
||||
tv.textStorage?.addAttribute(.foregroundColor, value: NSColor(color), range: range)
|
||||
}
|
||||
for (range, emphasis) in emphasizedRanges {
|
||||
let font: NSFont
|
||||
switch emphasis {
|
||||
case .keyword:
|
||||
font = boldKeywordFont
|
||||
case .comment:
|
||||
font = italicCommentFont
|
||||
}
|
||||
tv.textStorage?.addAttribute(.font, value: font, range: range)
|
||||
}
|
||||
|
||||
let nsTextMain = textSnapshot as NSString
|
||||
let selectedLocation = min(max(0, selected.location), max(0, fullRange.length))
|
||||
|
|
@ -3341,7 +3400,11 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
import UIKit
|
||||
|
||||
final class EditorInputTextView: UITextView {
|
||||
private let vimModeDefaultsKey = "EditorVimModeEnabled"
|
||||
private let vimInterceptionDefaultsKey = "EditorVimInterceptionEnabled"
|
||||
private let bracketTokens: [String] = ["(", ")", "{", "}", "[", "]", "<", ">", "'", "\"", "`", "()", "{}", "[]", "\"\"", "''"]
|
||||
private var isVimInsertMode: Bool = true
|
||||
private var pendingDeleteCurrentLineCommand = false
|
||||
|
||||
private lazy var bracketAccessoryView: UIView = {
|
||||
let host = UIView()
|
||||
|
|
@ -3408,11 +3471,29 @@ final class EditorInputTextView: UITextView {
|
|||
override init(frame: CGRect, textContainer: NSTextContainer?) {
|
||||
super.init(frame: frame, textContainer: textContainer)
|
||||
inputAccessoryView = bracketAccessoryView
|
||||
syncVimModeFromDefaults()
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleVimModeStateDidChange(_:)),
|
||||
name: .vimModeStateDidChange,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
inputAccessoryView = bracketAccessoryView
|
||||
syncVimModeFromDefaults()
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleVimModeStateDidChange(_:)),
|
||||
name: .vimModeStateDidChange,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
func setBracketAccessoryVisible(_ visible: Bool) {
|
||||
|
|
@ -3431,6 +3512,35 @@ final class EditorInputTextView: UITextView {
|
|||
return super.canPerformAction(action, withSender: sender)
|
||||
}
|
||||
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
guard UIDevice.current.userInterfaceIdiom == .pad else { return super.keyCommands }
|
||||
guard UserDefaults.standard.bool(forKey: vimInterceptionDefaultsKey),
|
||||
UserDefaults.standard.bool(forKey: vimModeDefaultsKey) else {
|
||||
return super.keyCommands
|
||||
}
|
||||
|
||||
var commands = super.keyCommands ?? []
|
||||
if isVimInsertMode {
|
||||
commands.append(vimCommand(input: UIKeyCommand.inputEscape, action: #selector(vimEscapeToNormalMode), title: "Vim: Normal Mode"))
|
||||
return commands
|
||||
}
|
||||
|
||||
commands.append(vimCommand(input: "h", action: #selector(vimMoveLeft), title: "Vim: Move Left"))
|
||||
commands.append(vimCommand(input: "j", action: #selector(vimMoveDown), title: "Vim: Move Down"))
|
||||
commands.append(vimCommand(input: "k", action: #selector(vimMoveUp), title: "Vim: Move Up"))
|
||||
commands.append(vimCommand(input: "l", action: #selector(vimMoveRight), title: "Vim: Move Right"))
|
||||
commands.append(vimCommand(input: "w", action: #selector(vimMoveWordForward), title: "Vim: Next Word"))
|
||||
commands.append(vimCommand(input: "b", action: #selector(vimMoveWordBackward), title: "Vim: Previous Word"))
|
||||
commands.append(vimCommand(input: "0", action: #selector(vimMoveToLineStart), title: "Vim: Line Start"))
|
||||
commands.append(vimCommand(input: UIKeyCommand.inputEscape, action: #selector(vimEscapeToNormalMode), title: "Vim: Stay in Normal Mode"))
|
||||
commands.append(vimCommand(input: "x", action: #selector(vimDeleteForward), title: "Vim: Delete Character"))
|
||||
commands.append(vimCommand(input: "i", action: #selector(vimEnterInsertMode), title: "Vim: Insert Mode"))
|
||||
commands.append(vimCommand(input: "a", action: #selector(vimAppendInsertMode), title: "Vim: Append Mode"))
|
||||
commands.append(vimCommand(input: "d", action: #selector(vimDeleteLineStep), title: "Vim: Delete Line"))
|
||||
commands.append(vimCommand(input: "$", modifiers: [.shift], action: #selector(vimMoveToLineEnd), title: "Vim: Line End"))
|
||||
return commands
|
||||
}
|
||||
|
||||
override func paste(_ sender: Any?) {
|
||||
// Force plain-text fallback so simulator/device paste remains reliable
|
||||
// even when the pasteboard advertises rich content first.
|
||||
|
|
@ -3483,6 +3593,270 @@ final class EditorInputTextView: UITextView {
|
|||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func vimCommand(
|
||||
input: String,
|
||||
modifiers: UIKeyModifierFlags = [],
|
||||
action: Selector,
|
||||
title: String
|
||||
) -> UIKeyCommand {
|
||||
let command = UIKeyCommand(input: input, modifierFlags: modifiers, action: action)
|
||||
command.discoverabilityTitle = title
|
||||
if #available(iOS 15.0, *) {
|
||||
command.wantsPriorityOverSystemBehavior = true
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
private func syncVimModeFromDefaults() {
|
||||
let enabled = UserDefaults.standard.bool(forKey: vimModeDefaultsKey)
|
||||
let interceptionEnabled = UserDefaults.standard.bool(forKey: vimInterceptionDefaultsKey)
|
||||
if !enabled || !interceptionEnabled {
|
||||
pendingDeleteCurrentLineCommand = false
|
||||
if !isVimInsertMode {
|
||||
setVimInsertMode(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setVimInsertMode(_ isInsertMode: Bool) {
|
||||
isVimInsertMode = isInsertMode
|
||||
pendingDeleteCurrentLineCommand = false
|
||||
NotificationCenter.default.post(
|
||||
name: .vimModeStateDidChange,
|
||||
object: nil,
|
||||
userInfo: ["insertMode": isInsertMode]
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func handleVimModeStateDidChange(_ notification: Notification) {
|
||||
if let insertMode = notification.userInfo?["insertMode"] as? Bool {
|
||||
isVimInsertMode = insertMode
|
||||
pendingDeleteCurrentLineCommand = false
|
||||
} else {
|
||||
syncVimModeFromDefaults()
|
||||
}
|
||||
}
|
||||
|
||||
private func vimText() -> NSString {
|
||||
(text ?? "") as NSString
|
||||
}
|
||||
|
||||
private func collapseSelection() -> NSRange {
|
||||
let current = selectedRange
|
||||
if current.length == 0 {
|
||||
return current
|
||||
}
|
||||
let collapsed = NSRange(location: current.location, length: 0)
|
||||
selectedRange = collapsed
|
||||
delegate?.textViewDidChangeSelection?(self)
|
||||
return collapsed
|
||||
}
|
||||
|
||||
private func moveCaret(to location: Int) {
|
||||
let length = vimText().length
|
||||
selectedRange = NSRange(location: max(0, min(location, length)), length: 0)
|
||||
scrollRangeToVisible(selectedRange)
|
||||
delegate?.textViewDidChangeSelection?(self)
|
||||
}
|
||||
|
||||
private func currentLineRange(for range: NSRange? = nil) -> NSRange {
|
||||
let target = range ?? collapseSelection()
|
||||
return vimText().lineRange(for: NSRange(location: target.location, length: 0))
|
||||
}
|
||||
|
||||
private func currentColumn(in lineRange: NSRange, location: Int) -> Int {
|
||||
max(0, location - lineRange.location)
|
||||
}
|
||||
|
||||
private func lineStartLocations() -> [Int] {
|
||||
let nsText = vimText()
|
||||
var starts: [Int] = [0]
|
||||
var location = 0
|
||||
while location < nsText.length {
|
||||
let lineRange = nsText.lineRange(for: NSRange(location: location, length: 0))
|
||||
let nextLocation = NSMaxRange(lineRange)
|
||||
if nextLocation >= nsText.length {
|
||||
break
|
||||
}
|
||||
starts.append(nextLocation)
|
||||
location = nextLocation
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
private func wordCharacterSetContains(_ scalar: UnicodeScalar) -> Bool {
|
||||
CharacterSet.alphanumerics.contains(scalar) || scalar == "_"
|
||||
}
|
||||
|
||||
private func moveWordForwardLocation(from location: Int) -> Int {
|
||||
let nsText = vimText()
|
||||
let length = nsText.length
|
||||
var index = min(max(0, location), length)
|
||||
while index < length {
|
||||
let codeUnit = nsText.character(at: index)
|
||||
guard let scalar = UnicodeScalar(codeUnit) else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if wordCharacterSetContains(scalar) {
|
||||
break
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
while index < length {
|
||||
let codeUnit = nsText.character(at: index)
|
||||
guard let scalar = UnicodeScalar(codeUnit) else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if !wordCharacterSetContains(scalar) {
|
||||
break
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
while index < length {
|
||||
let codeUnit = nsText.character(at: index)
|
||||
guard let scalar = UnicodeScalar(codeUnit) else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if wordCharacterSetContains(scalar) {
|
||||
break
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return min(index, length)
|
||||
}
|
||||
|
||||
private func moveWordBackwardLocation(from location: Int) -> Int {
|
||||
let nsText = vimText()
|
||||
var index = max(0, min(location, nsText.length))
|
||||
if index > 0 {
|
||||
index -= 1
|
||||
}
|
||||
while index > 0 {
|
||||
let codeUnit = nsText.character(at: index)
|
||||
guard let scalar = UnicodeScalar(codeUnit) else {
|
||||
index -= 1
|
||||
continue
|
||||
}
|
||||
if wordCharacterSetContains(scalar) {
|
||||
break
|
||||
}
|
||||
index -= 1
|
||||
}
|
||||
while index > 0 {
|
||||
let previous = nsText.character(at: index - 1)
|
||||
guard let scalar = UnicodeScalar(previous) else {
|
||||
index -= 1
|
||||
continue
|
||||
}
|
||||
if !wordCharacterSetContains(scalar) {
|
||||
break
|
||||
}
|
||||
index -= 1
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
private func deleteText(in range: NSRange) {
|
||||
guard range.length > 0 else { return }
|
||||
textStorage.replaceCharacters(in: range, with: "")
|
||||
selectedRange = NSRange(location: min(range.location, vimText().length), length: 0)
|
||||
delegate?.textViewDidChange?(self)
|
||||
delegate?.textViewDidChangeSelection?(self)
|
||||
}
|
||||
|
||||
@objc private func vimEscapeToNormalMode() {
|
||||
if isVimInsertMode {
|
||||
setVimInsertMode(false)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func vimEnterInsertMode() {
|
||||
setVimInsertMode(true)
|
||||
}
|
||||
|
||||
@objc private func vimAppendInsertMode() {
|
||||
let current = collapseSelection()
|
||||
if current.location < vimText().length {
|
||||
moveCaret(to: current.location + 1)
|
||||
}
|
||||
setVimInsertMode(true)
|
||||
}
|
||||
|
||||
@objc private func vimMoveLeft() {
|
||||
let current = collapseSelection()
|
||||
moveCaret(to: current.location - 1)
|
||||
}
|
||||
|
||||
@objc private func vimMoveRight() {
|
||||
let current = collapseSelection()
|
||||
moveCaret(to: current.location + 1)
|
||||
}
|
||||
|
||||
@objc private func vimMoveUp() {
|
||||
let current = collapseSelection()
|
||||
let starts = lineStartLocations()
|
||||
guard let lineIndex = starts.lastIndex(where: { $0 <= current.location }), lineIndex > 0 else { return }
|
||||
let currentLine = currentLineRange(for: current)
|
||||
let column = currentColumn(in: currentLine, location: current.location)
|
||||
let previousStart = starts[lineIndex - 1]
|
||||
let previousLine = vimText().lineRange(for: NSRange(location: previousStart, length: 0))
|
||||
let previousLineEnd = max(previousLine.location, previousLine.location + max(0, previousLine.length - 1))
|
||||
moveCaret(to: min(previousStart + column, previousLineEnd))
|
||||
}
|
||||
|
||||
@objc private func vimMoveDown() {
|
||||
let current = collapseSelection()
|
||||
let starts = lineStartLocations()
|
||||
guard let lineIndex = starts.lastIndex(where: { $0 <= current.location }), lineIndex + 1 < starts.count else { return }
|
||||
let currentLine = currentLineRange(for: current)
|
||||
let column = currentColumn(in: currentLine, location: current.location)
|
||||
let nextStart = starts[lineIndex + 1]
|
||||
let nextLine = vimText().lineRange(for: NSRange(location: nextStart, length: 0))
|
||||
let nextLineEnd = max(nextLine.location, nextLine.location + max(0, nextLine.length - 1))
|
||||
moveCaret(to: min(nextStart + column, nextLineEnd))
|
||||
}
|
||||
|
||||
@objc private func vimMoveWordForward() {
|
||||
moveCaret(to: moveWordForwardLocation(from: collapseSelection().location))
|
||||
}
|
||||
|
||||
@objc private func vimMoveWordBackward() {
|
||||
moveCaret(to: moveWordBackwardLocation(from: collapseSelection().location))
|
||||
}
|
||||
|
||||
@objc private func vimMoveToLineStart() {
|
||||
let lineRange = currentLineRange()
|
||||
moveCaret(to: lineRange.location)
|
||||
}
|
||||
|
||||
@objc private func vimMoveToLineEnd() {
|
||||
let lineRange = currentLineRange()
|
||||
let lineEnd = max(lineRange.location, lineRange.location + max(0, lineRange.length - 1))
|
||||
moveCaret(to: lineEnd)
|
||||
}
|
||||
|
||||
@objc private func vimDeleteForward() {
|
||||
let current = collapseSelection()
|
||||
guard current.location < vimText().length else { return }
|
||||
deleteText(in: NSRange(location: current.location, length: 1))
|
||||
}
|
||||
|
||||
@objc private func vimDeleteLineStep() {
|
||||
if pendingDeleteCurrentLineCommand {
|
||||
pendingDeleteCurrentLineCommand = false
|
||||
let lineRange = currentLineRange()
|
||||
deleteText(in: lineRange)
|
||||
return
|
||||
}
|
||||
pendingDeleteCurrentLineCommand = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
|
||||
self?.pendingDeleteCurrentLineCommand = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class LineNumberGutterView: UIView {
|
||||
|
|
@ -4322,7 +4696,9 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
)
|
||||
let syntaxProfile = syntaxProfile(for: language, text: nsText)
|
||||
let patterns = getSyntaxPatterns(for: language, colors: colors, profile: syntaxProfile)
|
||||
let emphasisPatterns = syntaxEmphasisPatterns(for: language, profile: syntaxProfile)
|
||||
var coloredRanges: [(NSRange, UIColor)] = []
|
||||
var emphasizedRanges: [(NSRange, SyntaxFontEmphasis)] = []
|
||||
|
||||
if let fastRanges = fastSyntaxColorRanges(
|
||||
language: language,
|
||||
|
|
@ -4347,6 +4723,28 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
}
|
||||
}
|
||||
|
||||
if theme.boldKeywords {
|
||||
for pattern in emphasisPatterns.keyword {
|
||||
guard let regex = cachedSyntaxRegex(pattern: pattern, options: [.anchorsMatchLines]) else { continue }
|
||||
let matches = regex.matches(in: text, range: applyRange)
|
||||
for match in matches {
|
||||
guard isValidRange(match.range, utf16Length: fullRange.length) else { continue }
|
||||
emphasizedRanges.append((match.range, .keyword))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if theme.italicComments {
|
||||
for pattern in emphasisPatterns.comment {
|
||||
guard let regex = cachedSyntaxRegex(pattern: pattern, options: [.anchorsMatchLines]) else { continue }
|
||||
let matches = regex.matches(in: text, range: applyRange)
|
||||
for match in matches {
|
||||
guard isValidRange(match.range, utf16Length: fullRange.length) else { continue }
|
||||
emphasizedRanges.append((match.range, .comment))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, let textView = self.textView else { return }
|
||||
guard generation == self.highlightGeneration else { return }
|
||||
|
|
@ -4374,9 +4772,21 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
textView.textStorage.removeAttribute(.underlineStyle, range: applyRange)
|
||||
textView.textStorage.addAttribute(.foregroundColor, value: baseColor, range: applyRange)
|
||||
textView.textStorage.addAttribute(.font, value: baseFont, range: applyRange)
|
||||
let boldKeywordFont = fontWithSymbolicTrait(baseFont, trait: .traitBold)
|
||||
let italicCommentFont = fontWithSymbolicTrait(baseFont, trait: .traitItalic)
|
||||
for (range, color) in coloredRanges {
|
||||
textView.textStorage.addAttribute(.foregroundColor, value: color, range: range)
|
||||
}
|
||||
for (range, emphasis) in emphasizedRanges {
|
||||
let font: UIFont
|
||||
switch emphasis {
|
||||
case .keyword:
|
||||
font = boldKeywordFont
|
||||
case .comment:
|
||||
font = italicCommentFont
|
||||
}
|
||||
textView.textStorage.addAttribute(.font, value: font, range: range)
|
||||
}
|
||||
let suppressLargeFileExtras = self.parent.isLargeFileMode
|
||||
let wantsBracketTokens = self.parent.highlightMatchingBrackets && !suppressLargeFileExtras
|
||||
let wantsScopeBackground = self.parent.highlightScopeBackground && !suppressLargeFileExtras
|
||||
|
|
|
|||
601
Neon Vision Editor/UI/MarkdownPreviewPDFRenderer.swift
Normal file
601
Neon Vision Editor/UI/MarkdownPreviewPDFRenderer.swift
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
import Foundation
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import CoreText
|
||||
#elseif canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
#if os(macOS) || os(iOS)
|
||||
import WebKit
|
||||
#endif
|
||||
|
||||
final class MarkdownPreviewPDFRenderer: NSObject, WKNavigationDelegate {
|
||||
enum ExportMode {
|
||||
case paginatedFit
|
||||
case onePageFit
|
||||
}
|
||||
|
||||
private var continuation: CheckedContinuation<Data, Error>?
|
||||
private var webView: WKWebView?
|
||||
private var retainedSelf: MarkdownPreviewPDFRenderer?
|
||||
private var sourceHTML: String = ""
|
||||
private var exportMode: ExportMode = .paginatedFit
|
||||
private var measuredBlockBottoms: [CGFloat] = []
|
||||
private static let exportMeasurementPadding: CGFloat = 28
|
||||
private static let exportBottomSafetyMargin: CGFloat = 1024
|
||||
private static let singlePagePadding: CGFloat = 28
|
||||
private static let a4PaperRect = CGRect(x: 0, y: 0, width: 595, height: 842)
|
||||
|
||||
@MainActor
|
||||
static func render(html: String, mode: ExportMode) async throws -> Data {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
let renderer = MarkdownPreviewPDFRenderer()
|
||||
renderer.retainedSelf = renderer
|
||||
renderer.continuation = continuation
|
||||
renderer.exportMode = mode
|
||||
renderer.sourceHTML = html
|
||||
renderer.start(html: html)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func start(html: String) {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.defaultWebpagePreferences.allowsContentJavaScript = true
|
||||
let webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
#if os(macOS)
|
||||
webView.setValue(false, forKey: "drawsBackground")
|
||||
#else
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .clear
|
||||
webView.scrollView.backgroundColor = .clear
|
||||
webView.scrollView.contentInsetAdjustmentBehavior = .never
|
||||
#endif
|
||||
webView.navigationDelegate = self
|
||||
let initialWidth: CGFloat
|
||||
switch exportMode {
|
||||
case .paginatedFit:
|
||||
initialWidth = Self.a4PaperRect.width
|
||||
case .onePageFit:
|
||||
initialWidth = 1280
|
||||
}
|
||||
webView.frame = CGRect(x: 0, y: 0, width: initialWidth, height: 1800)
|
||||
self.webView = webView
|
||||
webView.loadHTMLString(html, baseURL: nil)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
resetWebViewScrollPosition(webView)
|
||||
let script = """
|
||||
(async () => {
|
||||
window.scrollTo(0, 0);
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
const root = document.querySelector('.content') || body;
|
||||
const scrolling = document.scrollingElement || html;
|
||||
const exportPadding = \(Int(Self.exportMeasurementPadding));
|
||||
const bottomSafetyMargin = \(Int(Self.exportBottomSafetyMargin));
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
try { await document.fonts.ready; } catch (_) {}
|
||||
}
|
||||
body.style.margin = '0';
|
||||
body.style.padding = `${exportPadding}px`;
|
||||
body.style.overflow = 'visible';
|
||||
html.style.overflow = 'visible';
|
||||
body.style.height = 'auto';
|
||||
html.style.height = 'auto';
|
||||
await new Promise(resolve =>
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(resolve)
|
||||
)
|
||||
);
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const bodyRect = body.getBoundingClientRect();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(root);
|
||||
const rangeRect = range.getBoundingClientRect();
|
||||
const blockBottoms = Array.from(root.children)
|
||||
.map(node => Math.ceil(node.getBoundingClientRect().bottom - bodyRect.top))
|
||||
.filter(value => Number.isFinite(value) && value > 0);
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
||||
let lastElement = root;
|
||||
while (walker.nextNode()) {
|
||||
lastElement = walker.currentNode;
|
||||
}
|
||||
const lastElementRect = lastElement.getBoundingClientRect();
|
||||
const measuredBottom = Math.max(
|
||||
rootRect.bottom,
|
||||
rangeRect.bottom,
|
||||
lastElementRect.bottom
|
||||
);
|
||||
const width = Math.max(
|
||||
Math.ceil(body.scrollWidth),
|
||||
Math.ceil(html.scrollWidth),
|
||||
Math.ceil(scrolling.scrollWidth),
|
||||
Math.ceil(root.scrollWidth),
|
||||
Math.ceil(root.getBoundingClientRect().width) + exportPadding * 2,
|
||||
\(Int(Self.a4PaperRect.width))
|
||||
);
|
||||
const height = Math.max(
|
||||
Math.ceil(body.scrollHeight),
|
||||
Math.ceil(html.scrollHeight),
|
||||
Math.ceil(scrolling.scrollHeight),
|
||||
Math.ceil(scrolling.offsetHeight),
|
||||
Math.ceil(root.scrollHeight),
|
||||
Math.ceil(root.getBoundingClientRect().height) + exportPadding * 2,
|
||||
Math.ceil(measuredBottom - Math.min(bodyRect.top, rootRect.top)) + exportPadding * 2 + bottomSafetyMargin,
|
||||
900
|
||||
);
|
||||
return [width, height, blockBottoms];
|
||||
})();
|
||||
"""
|
||||
webView.evaluateJavaScript(script) { [weak self] value, error in
|
||||
guard let self else { return }
|
||||
self.measuredBlockBottoms = self.blockBottoms(from: value)
|
||||
let rect = self.bestEffortPDFRect(javaScriptValue: value, webView: webView, error: error)
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
self.resetWebViewScrollPosition(webView)
|
||||
let output: Data
|
||||
switch self.exportMode {
|
||||
case .onePageFit:
|
||||
try await self.prepareWebViewForPDFCapture(webView, rect: rect)
|
||||
let fullData = try await self.createPDFData(from: webView, rect: rect)
|
||||
output = self.fitPDFDataOnSinglePageIfNeeded(from: fullData)
|
||||
case .paginatedFit:
|
||||
output = try await self.paginatedPDFData(from: webView, fullRect: rect)
|
||||
}
|
||||
self.finish(with: .success(output))
|
||||
} catch {
|
||||
self.finish(with: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
finish(with: .failure(error))
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
finish(with: .failure(error))
|
||||
}
|
||||
|
||||
private func pdfRect(from javaScriptValue: Any?) -> CGRect? {
|
||||
guard let values = javaScriptValue as? [Any], values.count >= 2,
|
||||
let widthNumber = values[0] as? NSNumber,
|
||||
let heightNumber = values[1] as? NSNumber else { return nil }
|
||||
let width = max(640.0, min(8192.0, widthNumber.doubleValue))
|
||||
let height = max(900.0, heightNumber.doubleValue)
|
||||
return CGRect(x: 0, y: 0, width: width, height: height)
|
||||
}
|
||||
|
||||
private func blockBottoms(from javaScriptValue: Any?) -> [CGFloat] {
|
||||
guard let values = javaScriptValue as? [Any], values.count >= 3 else { return [] }
|
||||
guard let numbers = values[2] as? [NSNumber] else { return [] }
|
||||
return numbers.map { CGFloat($0.doubleValue) }.filter { $0.isFinite && $0 > 0 }.sorted()
|
||||
}
|
||||
|
||||
private func bestEffortPDFRect(javaScriptValue: Any?, webView: WKWebView, error: Error?) -> CGRect {
|
||||
let jsRect = pdfRect(from: javaScriptValue)
|
||||
let contentSize: CGSize
|
||||
#if os(macOS)
|
||||
contentSize = webView.enclosingScrollView?.documentView?.frame.size ?? .zero
|
||||
#else
|
||||
contentSize = webView.scrollView.contentSize
|
||||
#endif
|
||||
let scrollRect = CGRect(
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: max(640.0, min(8192.0, contentSize.width)),
|
||||
height: max(900.0, contentSize.height)
|
||||
)
|
||||
let fallbackRect = CGRect(x: 0, y: 0, width: 1024, height: 3000)
|
||||
if let jsRect {
|
||||
let mergedRect = CGRect(
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: max(jsRect.width, scrollRect.width),
|
||||
height: max(jsRect.height, scrollRect.height)
|
||||
)
|
||||
if error == nil {
|
||||
return mergedRect
|
||||
}
|
||||
return mergedRect
|
||||
}
|
||||
if scrollRect.height > 1200 {
|
||||
return scrollRect
|
||||
}
|
||||
return fallbackRect
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func resetWebViewScrollPosition(_ webView: WKWebView) {
|
||||
#if os(macOS)
|
||||
if let clipView = webView.enclosingScrollView?.contentView {
|
||||
clipView.scroll(to: .zero)
|
||||
webView.enclosingScrollView?.reflectScrolledClipView(clipView)
|
||||
}
|
||||
#else
|
||||
webView.scrollView.setContentOffset(.zero, animated: false)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func finish(with result: Result<Data, Error>) {
|
||||
guard let continuation else { return }
|
||||
self.continuation = nil
|
||||
switch result {
|
||||
case .success(let data):
|
||||
continuation.resume(returning: data)
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
webView?.navigationDelegate = nil
|
||||
webView = nil
|
||||
retainedSelf = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func createPDFData(from webView: WKWebView, rect: CGRect) async throws -> Data {
|
||||
#if os(macOS)
|
||||
let captureRect = CGRect(origin: .zero, size: rect.size)
|
||||
let data = webView.dataWithPDF(inside: captureRect)
|
||||
if isUsablePDFData(data) {
|
||||
return data
|
||||
}
|
||||
#endif
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let config = WKPDFConfiguration()
|
||||
config.rect = rect
|
||||
webView.createPDF(configuration: config) { result in
|
||||
switch result {
|
||||
case .success(let data):
|
||||
continuation.resume(returning: data)
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func prepareWebViewForPDFCapture(_ webView: WKWebView, rect: CGRect) async throws {
|
||||
webView.frame = rect
|
||||
resetWebViewScrollPosition(webView)
|
||||
#if os(macOS)
|
||||
webView.layoutSubtreeIfNeeded()
|
||||
#else
|
||||
webView.layoutIfNeeded()
|
||||
#endif
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
#if os(macOS)
|
||||
webView.layoutSubtreeIfNeeded()
|
||||
#else
|
||||
webView.layoutIfNeeded()
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func paginatedPDFData(from webView: WKWebView, fullRect: CGRect) async throws -> Data {
|
||||
try await prepareWebViewForPDFCapture(webView, rect: fullRect)
|
||||
#if os(macOS)
|
||||
if let attributedPaginated = macPaginatedAttributedPDFData(fromHTML: sourceHTML),
|
||||
isUsablePDFData(attributedPaginated) {
|
||||
return attributedPaginated
|
||||
}
|
||||
if let nativePaginated = macPaginatedPDFData(from: webView, rect: fullRect),
|
||||
isUsablePDFData(nativePaginated) {
|
||||
return nativePaginated
|
||||
}
|
||||
#endif
|
||||
let fullData = try await createPDFData(from: webView, rect: fullRect)
|
||||
if let paginated = paginatedA4PDFData(
|
||||
fromSinglePagePDF: fullData,
|
||||
preferredBlockBottoms: measuredBlockBottoms
|
||||
) {
|
||||
return paginated
|
||||
}
|
||||
return fullData
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func macPaginatedAttributedPDFData(fromHTML html: String) -> Data? {
|
||||
guard let htmlData = html.data(using: .utf8) else { return nil }
|
||||
let readingOptions: [NSAttributedString.DocumentReadingOptionKey: Any] = [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
]
|
||||
guard let attributed = try? NSMutableAttributedString(
|
||||
data: htmlData,
|
||||
options: readingOptions,
|
||||
documentAttributes: nil
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fullRange = NSRange(location: 0, length: attributed.length)
|
||||
attributed.addAttribute(.foregroundColor, value: NSColor.black, range: fullRange)
|
||||
|
||||
let framesetter = CTFramesetterCreateWithAttributedString(attributed as CFAttributedString)
|
||||
let outputData = NSMutableData()
|
||||
guard
|
||||
let consumer = CGDataConsumer(data: outputData as CFMutableData),
|
||||
let context = CGContext(consumer: consumer, mediaBox: nil, nil)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let paperRect = Self.a4PaperRect
|
||||
let printableRect = paperRect.insetBy(dx: 36, dy: 36)
|
||||
let textFrameRect = printableRect.insetBy(dx: 0, dy: 14)
|
||||
let pageInfo: [CFString: Any] = [kCGPDFContextMediaBox: paperRect]
|
||||
var currentRange = CFRange(location: 0, length: 0)
|
||||
|
||||
repeat {
|
||||
context.beginPDFPage(pageInfo as CFDictionary)
|
||||
context.saveGState()
|
||||
context.setFillColor(NSColor.white.cgColor)
|
||||
context.fill(paperRect)
|
||||
context.textMatrix = .identity
|
||||
|
||||
let path = CGMutablePath()
|
||||
path.addRect(textFrameRect)
|
||||
let frame = CTFramesetterCreateFrame(framesetter, currentRange, path, nil)
|
||||
CTFrameDraw(frame, context)
|
||||
context.restoreGState()
|
||||
context.endPDFPage()
|
||||
|
||||
let visibleRange = CTFrameGetVisibleStringRange(frame)
|
||||
guard visibleRange.length > 0 else {
|
||||
context.closePDF()
|
||||
return nil
|
||||
}
|
||||
currentRange.location += visibleRange.length
|
||||
} while currentRange.location < attributed.length
|
||||
|
||||
context.closePDF()
|
||||
let result = outputData as Data
|
||||
return isUsablePDFData(result) ? result : nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func macPaginatedPDFData(from webView: WKWebView, rect: CGRect) -> Data? {
|
||||
let printInfo = NSPrintInfo.shared.copy() as? NSPrintInfo ?? NSPrintInfo()
|
||||
printInfo.paperSize = NSSize(width: Self.a4PaperRect.width, height: Self.a4PaperRect.height)
|
||||
printInfo.leftMargin = 36
|
||||
printInfo.rightMargin = 36
|
||||
printInfo.topMargin = 36
|
||||
printInfo.bottomMargin = 36
|
||||
printInfo.horizontalPagination = .automatic
|
||||
printInfo.verticalPagination = .automatic
|
||||
printInfo.isHorizontallyCentered = false
|
||||
printInfo.isVerticallyCentered = false
|
||||
|
||||
let outputData = NSMutableData()
|
||||
let operation = NSPrintOperation.pdfOperation(
|
||||
with: webView,
|
||||
inside: CGRect(origin: .zero, size: rect.size),
|
||||
to: outputData,
|
||||
printInfo: printInfo
|
||||
)
|
||||
operation.showsPrintPanel = false
|
||||
operation.showsProgressPanel = false
|
||||
guard operation.run() else { return nil }
|
||||
|
||||
let result = outputData as Data
|
||||
return isUsablePDFData(result) ? result : nil
|
||||
}
|
||||
#endif
|
||||
|
||||
private func isUsablePDFData(_ data: Data) -> Bool {
|
||||
guard data.count > 2_000,
|
||||
let provider = CGDataProvider(data: data as CFData),
|
||||
let document = CGPDFDocument(provider),
|
||||
document.numberOfPages > 0,
|
||||
let firstPage = document.page(at: 1)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
let rect = firstPage.getBoxRect(.cropBox).standardized
|
||||
return rect.width > 0 && rect.height > 0
|
||||
}
|
||||
|
||||
private func paginatedA4PDFData(fromSinglePagePDF data: Data, preferredBlockBottoms: [CGFloat]) -> Data? {
|
||||
let normalizedData = stitchedSinglePagePDFDataIfNeeded(from: data) ?? data
|
||||
guard
|
||||
let provider = CGDataProvider(data: normalizedData as CFData),
|
||||
let sourceDocument = CGPDFDocument(provider),
|
||||
sourceDocument.numberOfPages >= 1,
|
||||
let sourcePage = sourceDocument.page(at: 1)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let sourceRect = sourcePage.getBoxRect(.cropBox).standardized
|
||||
guard sourceRect.width > 1, sourceRect.height > 1 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let paperRect = Self.a4PaperRect
|
||||
let printableRect = paperRect.insetBy(dx: 36, dy: 36)
|
||||
let scale = max(0.001, min(printableRect.width / sourceRect.width, 1.0))
|
||||
let sourceSliceHeight = max(printableRect.height / scale, 1.0)
|
||||
let pageRanges = paginatedSourceRanges(
|
||||
sourceHeight: sourceRect.height,
|
||||
preferredBlockBottoms: preferredBlockBottoms,
|
||||
sliceHeight: sourceSliceHeight
|
||||
)
|
||||
|
||||
let outputData = NSMutableData()
|
||||
guard
|
||||
let consumer = CGDataConsumer(data: outputData as CFMutableData),
|
||||
let context = CGContext(consumer: consumer, mediaBox: nil, nil)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let mediaBoxInfo: [CFString: Any] = [kCGPDFContextMediaBox: paperRect]
|
||||
for range in pageRanges {
|
||||
let sliceBottomY = max(sourceRect.minY, min(sourceRect.maxY, sourceRect.maxY - range.bottom))
|
||||
let sliceHeight = max((range.bottom - range.top) * scale, 1.0)
|
||||
let contentRect = CGRect(
|
||||
x: printableRect.minX,
|
||||
y: printableRect.maxY - min(sliceHeight, printableRect.height),
|
||||
width: printableRect.width,
|
||||
height: min(sliceHeight, printableRect.height)
|
||||
)
|
||||
|
||||
context.beginPDFPage(mediaBoxInfo as CFDictionary)
|
||||
context.saveGState()
|
||||
context.clip(to: contentRect)
|
||||
context.translateBy(
|
||||
x: printableRect.minX - (sourceRect.minX * scale),
|
||||
y: contentRect.minY - (sliceBottomY * scale)
|
||||
)
|
||||
context.scaleBy(x: scale, y: scale)
|
||||
context.drawPDFPage(sourcePage)
|
||||
context.restoreGState()
|
||||
context.endPDFPage()
|
||||
}
|
||||
context.closePDF()
|
||||
|
||||
let result = outputData as Data
|
||||
return isUsablePDFData(result) ? result : nil
|
||||
}
|
||||
|
||||
private func paginatedSourceRanges(
|
||||
sourceHeight: CGFloat,
|
||||
preferredBlockBottoms: [CGFloat],
|
||||
sliceHeight: CGFloat
|
||||
) -> [(top: CGFloat, bottom: CGFloat)] {
|
||||
let sortedBottoms = preferredBlockBottoms
|
||||
.filter { $0 > 0 && $0 < sourceHeight }
|
||||
.sorted()
|
||||
|
||||
let minimumFill = max(sliceHeight * 0.55, 1.0)
|
||||
var ranges: [(top: CGFloat, bottom: CGFloat)] = []
|
||||
var pageTop: CGFloat = 0
|
||||
|
||||
while pageTop < sourceHeight - 0.5 {
|
||||
let tentativeBottom = min(pageTop + sliceHeight, sourceHeight)
|
||||
let minimumBottom = min(sourceHeight, pageTop + minimumFill)
|
||||
let preferredBottom = sortedBottoms.last(where: { $0 >= minimumBottom && $0 <= tentativeBottom }) ?? tentativeBottom
|
||||
let pageBottom = max(preferredBottom, min(tentativeBottom, sourceHeight))
|
||||
|
||||
ranges.append((top: pageTop, bottom: pageBottom))
|
||||
|
||||
if pageBottom >= sourceHeight - 0.5 {
|
||||
break
|
||||
}
|
||||
|
||||
pageTop = pageBottom
|
||||
}
|
||||
|
||||
if ranges.isEmpty {
|
||||
return [(top: 0, bottom: sourceHeight)]
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
private func fitPDFDataOnSinglePageIfNeeded(from data: Data) -> Data {
|
||||
let normalizedData = stitchedSinglePagePDFDataIfNeeded(from: data) ?? data
|
||||
guard
|
||||
let provider = CGDataProvider(data: normalizedData as CFData),
|
||||
let sourceDocument = CGPDFDocument(provider),
|
||||
sourceDocument.numberOfPages >= 1,
|
||||
let sourcePage = sourceDocument.page(at: 1)
|
||||
else {
|
||||
return data
|
||||
}
|
||||
|
||||
let sourceRect = sourcePage.getBoxRect(.cropBox).standardized
|
||||
guard sourceRect.width > 0, sourceRect.height > 0 else {
|
||||
return data
|
||||
}
|
||||
|
||||
let outputRect = Self.a4PaperRect
|
||||
let contentRect = outputRect.insetBy(dx: Self.singlePagePadding, dy: Self.singlePagePadding)
|
||||
|
||||
let outputData = NSMutableData()
|
||||
guard
|
||||
let consumer = CGDataConsumer(data: outputData as CFMutableData),
|
||||
let context = CGContext(consumer: consumer, mediaBox: nil, nil)
|
||||
else {
|
||||
return data
|
||||
}
|
||||
|
||||
let pageInfo: [CFString: Any] = [kCGPDFContextMediaBox: outputRect]
|
||||
context.beginPDFPage(pageInfo as CFDictionary)
|
||||
context.saveGState()
|
||||
let transform = sourcePage.getDrawingTransform(
|
||||
.cropBox,
|
||||
rect: contentRect,
|
||||
rotate: 0,
|
||||
preserveAspectRatio: true
|
||||
)
|
||||
context.concatenate(transform)
|
||||
context.drawPDFPage(sourcePage)
|
||||
context.restoreGState()
|
||||
context.endPDFPage()
|
||||
context.closePDF()
|
||||
return outputData as Data
|
||||
}
|
||||
|
||||
private func stitchedSinglePagePDFDataIfNeeded(from data: Data) -> Data? {
|
||||
guard
|
||||
let provider = CGDataProvider(data: data as CFData),
|
||||
let sourceDocument = CGPDFDocument(provider),
|
||||
sourceDocument.numberOfPages > 1
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var pages: [(page: CGPDFPage, rect: CGRect)] = []
|
||||
var maxWidth: CGFloat = 0
|
||||
var totalHeight: CGFloat = 0
|
||||
|
||||
for index in 1...sourceDocument.numberOfPages {
|
||||
guard let page = sourceDocument.page(at: index) else { continue }
|
||||
let rect = page.getBoxRect(.cropBox).standardized
|
||||
guard rect.width > 0, rect.height > 0 else { continue }
|
||||
pages.append((page, rect))
|
||||
maxWidth = max(maxWidth, rect.width)
|
||||
totalHeight += rect.height
|
||||
}
|
||||
|
||||
guard !pages.isEmpty, maxWidth > 0, totalHeight > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let outputRect = CGRect(x: 0, y: 0, width: maxWidth, height: totalHeight)
|
||||
let outputData = NSMutableData()
|
||||
guard
|
||||
let consumer = CGDataConsumer(data: outputData as CFMutableData),
|
||||
let context = CGContext(consumer: consumer, mediaBox: nil, nil)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let pageInfo: [CFString: Any] = [kCGPDFContextMediaBox: outputRect]
|
||||
context.beginPDFPage(pageInfo as CFDictionary)
|
||||
|
||||
var currentTop = outputRect.maxY
|
||||
for entry in pages {
|
||||
currentTop -= entry.rect.height
|
||||
context.saveGState()
|
||||
context.translateBy(
|
||||
x: -entry.rect.minX,
|
||||
y: currentTop - entry.rect.minY
|
||||
)
|
||||
context.drawPDFPage(entry.page)
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
context.endPDFPage()
|
||||
context.closePDF()
|
||||
|
||||
let result = outputData as Data
|
||||
return isUsablePDFData(result) ? result : nil
|
||||
}
|
||||
}
|
||||
|
|
@ -94,6 +94,10 @@ struct NeonSettingsView: View {
|
|||
@AppStorage("SettingsThemeCommentColor") private var themeCommentHex: String = "#7F8C98"
|
||||
@AppStorage("SettingsThemeTypeColor") private var themeTypeHex: String = "#32D269"
|
||||
@AppStorage("SettingsThemeBuiltinColor") private var themeBuiltinHex: String = "#EC7887"
|
||||
@AppStorage("SettingsThemeBoldKeywords") private var themeBoldKeywords: Bool = false
|
||||
@AppStorage("SettingsThemeItalicComments") private var themeItalicComments: Bool = false
|
||||
@AppStorage("SettingsThemeUnderlineLinks") private var themeUnderlineLinks: Bool = false
|
||||
@AppStorage("SettingsThemeBoldMarkdownHeadings") private var themeBoldMarkdownHeadings: Bool = false
|
||||
|
||||
private var inputFieldBackground: Color {
|
||||
#if os(macOS)
|
||||
|
|
@ -289,6 +293,10 @@ struct NeonSettingsView: View {
|
|||
)
|
||||
#endif
|
||||
.preferredColorScheme(preferredColorSchemeOverride)
|
||||
#if os(iOS)
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.onAppear {
|
||||
settingsActiveTab = Self.defaultSettingsTab
|
||||
if moreSectionTab.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
|
|
@ -1377,7 +1385,7 @@ struct NeonSettingsView: View {
|
|||
subtitle: "Pick a preset or customize token colors for your editing environment."
|
||||
)
|
||||
HStack(alignment: .top, spacing: UI.space16) {
|
||||
Group {
|
||||
VStack(alignment: .leading, spacing: UI.space12) {
|
||||
#if os(macOS)
|
||||
let listView = List(themes, id: \.self, selection: $selectedTheme) { theme in
|
||||
HStack {
|
||||
|
|
@ -1430,6 +1438,27 @@ struct NeonSettingsView: View {
|
|||
listView
|
||||
}
|
||||
#endif
|
||||
VStack(alignment: .leading, spacing: UI.space10) {
|
||||
Text("Formatting")
|
||||
.font(Typography.sectionSubheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(.flexible(minimum: 140), spacing: UI.space12, alignment: .leading),
|
||||
GridItem(.flexible(minimum: 140), spacing: UI.space12, alignment: .leading)
|
||||
],
|
||||
alignment: .leading,
|
||||
spacing: UI.space8
|
||||
) {
|
||||
Toggle("Bold keywords", isOn: $themeBoldKeywords)
|
||||
Toggle("Italic comments", isOn: $themeItalicComments)
|
||||
Toggle("Underline links", isOn: $themeUnderlineLinks)
|
||||
Toggle("Bold Markdown headings", isOn: $themeBoldMarkdownHeadings)
|
||||
}
|
||||
}
|
||||
.padding(UI.space12)
|
||||
.background(settingsCardBackground(cornerRadius: UI.cardCorner))
|
||||
}
|
||||
.padding(UI.space8)
|
||||
.background(settingsCardBackground(cornerRadius: UI.cardCorner))
|
||||
|
|
@ -1506,7 +1535,7 @@ struct NeonSettingsView: View {
|
|||
.padding(UI.space12)
|
||||
.background(settingsCardBackground(cornerRadius: UI.cardCorner))
|
||||
|
||||
Text(isCustom ? "Custom theme applies immediately." : "Select Custom to edit colors.")
|
||||
Text(isCustom ? "Custom theme applies immediately. Formatting applies to every active theme." : "Select Custom to edit colors. Formatting applies to every active theme.")
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
|
@ -1859,10 +1888,10 @@ struct NeonSettingsView: View {
|
|||
.font(Typography.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
Text("Staged update: \(appUpdateManager.stagedUpdateVersionSummary)")
|
||||
Text(localized("Staged update: %@", appUpdateManager.stagedUpdateVersionSummary))
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Last install attempt: \(appUpdateManager.lastInstallAttemptSummary)")
|
||||
Text(localized("Last install attempt: %@", appUpdateManager.lastInstallAttemptSummary))
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
|
|
@ -2087,26 +2116,31 @@ struct NeonSettingsView: View {
|
|||
.padding(.top, UI.space6)
|
||||
.padding(.bottom, UI.space6)
|
||||
} else {
|
||||
VStack(alignment: .center, spacing: UI.space8) {
|
||||
HStack(alignment: .top, spacing: UI.space12) {
|
||||
Image(systemName: icon)
|
||||
.font(.title2.weight(.semibold))
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 36, height: 36, alignment: .center)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: UI.space6) {
|
||||
Text(title)
|
||||
.font(Typography.sectionTitle)
|
||||
.multilineTextAlignment(.center)
|
||||
.multilineTextAlignment(.leading)
|
||||
Text(subtitle)
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, UI.mobileHeaderTopPadding)
|
||||
.padding(.bottom, UI.space6)
|
||||
}
|
||||
}
|
||||
#else
|
||||
VStack(alignment: .center, spacing: UI.space8) {
|
||||
HStack(alignment: .top, spacing: UI.space12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: UI.macHeaderBadgeCorner, style: .continuous)
|
||||
.fill(Color.accentColor.opacity(0.10))
|
||||
|
|
@ -2119,15 +2153,20 @@ struct NeonSettingsView: View {
|
|||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
.frame(width: UI.macHeaderIconSize, height: UI.macHeaderIconSize)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: UI.space6) {
|
||||
Text(title)
|
||||
.font(Typography.sectionTitle)
|
||||
.multilineTextAlignment(.center)
|
||||
.multilineTextAlignment(.leading)
|
||||
Text(subtitle)
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.overlay(alignment: .bottom) {
|
||||
Divider().opacity(0.45)
|
||||
}
|
||||
|
|
@ -2437,16 +2476,35 @@ struct NeonSettingsView: View {
|
|||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("func computeTotal(_ values: [Int]) -> Int {")
|
||||
.foregroundStyle(previewTheme.syntax.keyword)
|
||||
themePreviewLine(
|
||||
"# Release Notes",
|
||||
color: previewTheme.syntax.meta,
|
||||
weight: previewTheme.boldMarkdownHeadings ? .bold : .regular
|
||||
)
|
||||
themePreviewLine(
|
||||
"[docs](https://example.com/theme-guide)",
|
||||
color: previewTheme.syntax.string,
|
||||
underline: previewTheme.underlineLinks
|
||||
)
|
||||
themePreviewLine(
|
||||
"func computeTotal(_ values: [Int]) -> Int {",
|
||||
color: previewTheme.syntax.keyword,
|
||||
weight: previewTheme.boldKeywords ? .bold : .regular
|
||||
)
|
||||
Text(" let sum = values.reduce(0, +)")
|
||||
.foregroundStyle(previewTheme.text)
|
||||
Text(" // tax adjustment")
|
||||
.foregroundStyle(previewTheme.syntax.comment)
|
||||
themePreviewLine(
|
||||
" // tax adjustment",
|
||||
color: previewTheme.syntax.comment,
|
||||
italic: previewTheme.italicComments
|
||||
)
|
||||
Text(" return sum + 42")
|
||||
.foregroundStyle(previewTheme.syntax.number)
|
||||
Text("}")
|
||||
.foregroundStyle(previewTheme.syntax.keyword)
|
||||
themePreviewLine(
|
||||
"}",
|
||||
color: previewTheme.syntax.keyword,
|
||||
weight: previewTheme.boldKeywords ? .bold : .regular
|
||||
)
|
||||
}
|
||||
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
||||
.padding(UI.space10)
|
||||
|
|
@ -2461,6 +2519,25 @@ struct NeonSettingsView: View {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func themePreviewLine(
|
||||
_ text: String,
|
||||
color: Color,
|
||||
weight: Font.Weight = .regular,
|
||||
italic: Bool = false,
|
||||
underline: Bool = false
|
||||
) -> some View {
|
||||
let line = Text(text)
|
||||
.foregroundStyle(color)
|
||||
.font(.system(size: 12, weight: weight, design: .monospaced))
|
||||
let formattedLine = italic ? line.italic() : line
|
||||
if underline {
|
||||
formattedLine.underline()
|
||||
} else {
|
||||
formattedLine
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,24 @@ struct PlainTextDocument: FileDocument {
|
|||
}
|
||||
}
|
||||
|
||||
struct PDFExportDocument: FileDocument {
|
||||
static var readableContentTypes: [UTType] { [.pdf] }
|
||||
|
||||
var data: Data
|
||||
|
||||
init(data: Data = Data()) {
|
||||
self.data = data
|
||||
}
|
||||
|
||||
init(configuration: ReadConfiguration) throws {
|
||||
self.data = configuration.file.regularFileContents ?? Data()
|
||||
}
|
||||
|
||||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
|
||||
FileWrapper(regularFileWithContents: data)
|
||||
}
|
||||
}
|
||||
|
||||
struct APISupportSettingsView: View {
|
||||
@Binding var grokAPIToken: String
|
||||
@Binding var openAIAPIToken: String
|
||||
|
|
@ -359,12 +377,12 @@ struct WelcomeTourView: View {
|
|||
private let pages: [TourPage] = [
|
||||
TourPage(
|
||||
title: "What’s New in This Release",
|
||||
subtitle: "Major changes since v0.5.4:",
|
||||
subtitle: "Major changes since v0.5.5:",
|
||||
bullets: [
|
||||
"Stabilized first-open rendering from the project sidebar so file content and syntax highlighting appear on first click without requiring tab switches.",
|
||||
"Hardened startup/session behavior so `Reopen Last Session` reliably wins over conflicting blank-document startup states.",
|
||||
"Refined large-file activation and loading placeholders to avoid misclassifying smaller files as large-file sessions.",
|
||||
"Added Share Shot (`Code Snapshot`) creation flow with toolbar + selection-context actions (`camera.viewfinder`) and a styled share/export composer."
|
||||
"",
|
||||
"Safe Mode now recovers from repeated failed launches without getting stuck on every normal restart.",
|
||||
"Large project folders now get a background file index that feeds `Quick Open` and `Find in Files` instead of relying only on live folder scans.",
|
||||
"Theme formatting and Settings polish now apply immediately, with better localization and an iPad hardware-keyboard Vim MVP."
|
||||
],
|
||||
iconName: "sparkles.rectangle.stack",
|
||||
colors: [Color(red: 0.40, green: 0.28, blue: 0.90), Color(red: 0.96, green: 0.46, blue: 0.55)],
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ struct EditorTheme {
|
|||
let cursor: Color
|
||||
let selection: Color
|
||||
let syntax: SyntaxColors
|
||||
let boldKeywords: Bool
|
||||
let italicComments: Bool
|
||||
let underlineLinks: Bool
|
||||
let boldMarkdownHeadings: Bool
|
||||
}
|
||||
|
||||
private struct ThemePalette {
|
||||
|
|
@ -603,6 +607,10 @@ func currentEditorTheme(colorScheme: ColorScheme) -> EditorTheme {
|
|||
let defaults = UserDefaults.standard
|
||||
// Always respect the user's selected theme across iOS and macOS.
|
||||
let name = canonicalThemeName(defaults.string(forKey: "SettingsThemeName") ?? "Neon Glow")
|
||||
let boldKeywords = defaults.bool(forKey: "SettingsThemeBoldKeywords")
|
||||
let italicComments = defaults.bool(forKey: "SettingsThemeItalicComments")
|
||||
let underlineLinks = defaults.bool(forKey: "SettingsThemeUnderlineLinks")
|
||||
let boldMarkdownHeadings = defaults.bool(forKey: "SettingsThemeBoldMarkdownHeadings")
|
||||
let palette = paletteForThemeName(name, defaults: defaults)
|
||||
// Keep base editor text legible and consistent across all themes.
|
||||
// Neon Glow gets a slightly brighter dark-mode text tone.
|
||||
|
|
@ -700,7 +708,11 @@ func currentEditorTheme(colorScheme: ColorScheme) -> EditorTheme {
|
|||
background: modeAdjustedEditorBackground(palette.background, colorScheme: colorScheme),
|
||||
cursor: palette.cursor,
|
||||
selection: palette.selection,
|
||||
syntax: syntax
|
||||
syntax: syntax,
|
||||
boldKeywords: boldKeywords,
|
||||
italicComments: italicComments,
|
||||
underlineLinks: underlineLinks,
|
||||
boldMarkdownHeadings: boldMarkdownHeadings
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -165,6 +165,51 @@
|
|||
"Base" = "Basis";
|
||||
"Syntax" = "Syntax";
|
||||
"Preview" = "Vorschau";
|
||||
"Current Setup" = "Aktuelle Konfiguration";
|
||||
"Snapshot updates immediately as settings change." = "Die Übersicht aktualisiert sich sofort bei jeder Einstellungsänderung.";
|
||||
"Window behavior, startup defaults, and confirmation preferences." = "Fensterverhalten, Startvorgaben und Bestätigungseinstellungen.";
|
||||
"Toolbar Symbols" = "Toolbar-Symbole";
|
||||
"Blue" = "Blau";
|
||||
"Dark Gray" = "Dunkelgrau";
|
||||
"Black" = "Schwarz";
|
||||
"Display, indentation, editing behavior, and completion sources." = "Anzeige, Einrückung, Bearbeitungsverhalten und Vervollständigungsquellen.";
|
||||
"Section" = "Bereich";
|
||||
"Basics" = "Grundlagen";
|
||||
"Behavior" = "Verhalten";
|
||||
"Layout" = "Layout";
|
||||
"Left" = "Links";
|
||||
"Right" = "Rechts";
|
||||
"Editor Basics" = "Editor-Grundlagen";
|
||||
"Editor Behavior" = "Editor-Verhalten";
|
||||
"Performance" = "Leistung";
|
||||
"Balanced" = "Ausgewogen";
|
||||
"Large Files" = "Große Dateien";
|
||||
"Battery" = "Batterie";
|
||||
"Balanced keeps default behavior. Large Files and Battery enter performance mode earlier." = "Ausgewogen behält das Standardverhalten bei. Große Dateien und Batterie wechseln früher in den Performance-Modus.";
|
||||
"Off" = "Aus";
|
||||
"Minimal" = "Minimal";
|
||||
"Standard" = "Standard";
|
||||
"Deferred" = "Verzögert";
|
||||
"Plain Text" = "Nur Text";
|
||||
"Minimal colors only visible JSON lines plus a small buffer using a strict work budget." = "Minimal färbt nur sichtbare JSON-Zeilen plus einen kleinen Puffer innerhalb eines strengen Arbeitsbudgets.";
|
||||
"Deferred uses a lightweight loading step and chunked editor install. Plain Text keeps large-file sessions unstyled." = "Verzögert nutzt einen leichten Ladeschritt und eine gestückelte Editor-Initialisierung. Nur Text lässt große Dateien ungestylt.";
|
||||
"Pick a preset or customize token colors for your editing environment." = "Wähle ein Preset oder passe die Token-Farben für deine Bearbeitungsumgebung an.";
|
||||
"Formatting" = "Formatierung";
|
||||
"Bold keywords" = "Schlüsselwörter fett";
|
||||
"Italic comments" = "Kommentare kursiv";
|
||||
"Underline links" = "Links unterstreichen";
|
||||
"Bold Markdown headings" = "Markdown-Überschriften fett";
|
||||
"Custom theme applies immediately. Formatting applies to every active theme." = "Das benutzerdefinierte Design wird sofort angewendet. Die Formatierung gilt für jedes aktive Design.";
|
||||
"Select Custom to edit colors. Formatting applies to every active theme." = "Wähle Benutzerdefiniert, um Farben zu bearbeiten. Die Formatierung gilt für jedes aktive Design.";
|
||||
"AI setup, provider credentials, and support options." = "KI-Einrichtung, Anbieter-Zugangsdaten und Support-Optionen.";
|
||||
"AI model, privacy disclosure, and provider credentials." = "KI-Modell, Datenschutzhinweise und Anbieter-Zugangsdaten.";
|
||||
"Safe local diagnostics for update and file-open troubleshooting." = "Sichere lokale Diagnose für Update- und Dateiöffnungs-Fehlersuche.";
|
||||
"Updater" = "Updater";
|
||||
"Recent updater log" = "Letztes Updater-Protokoll";
|
||||
"File Open Timing" = "Dateiöffnungs-Zeitwerte";
|
||||
"No recent file-open snapshots yet." = "Noch keine aktuellen Dateiöffnungs-Snapshots.";
|
||||
"Staged update: %@" = "Bereitgestelltes Update: %@";
|
||||
"Last install attempt: %@" = "Letzter Installationsversuch: %@";
|
||||
"Tip: Enable only one startup mode to keep app launch behavior predictable." = "Tipp: Aktiviere nur einen Startmodus, damit das Startverhalten der App vorhersehbar bleibt.";
|
||||
"When Line Wrap is enabled, scope guides/scoped region are turned off to avoid layout conflicts." = "Wenn Zeilenumbruch aktiv ist, werden Scope-Guides/Scope-Bereich deaktiviert, um Layoutkonflikte zu vermeiden.";
|
||||
"Scope guides are intended for non-Swift languages. Swift favors matching-token highlight." = "Scope-Guides sind für Nicht-Swift-Sprachen gedacht. Für Swift wird die Hervorhebung passender Tokens bevorzugt.";
|
||||
|
|
|
|||
|
|
@ -144,6 +144,51 @@
|
|||
"Base" = "Base";
|
||||
"Syntax" = "Syntax";
|
||||
"Preview" = "Preview";
|
||||
"Current Setup" = "Current Setup";
|
||||
"Snapshot updates immediately as settings change." = "Snapshot updates immediately as settings change.";
|
||||
"Window behavior, startup defaults, and confirmation preferences." = "Window behavior, startup defaults, and confirmation preferences.";
|
||||
"Toolbar Symbols" = "Toolbar Symbols";
|
||||
"Blue" = "Blue";
|
||||
"Dark Gray" = "Dark Gray";
|
||||
"Black" = "Black";
|
||||
"Display, indentation, editing behavior, and completion sources." = "Display, indentation, editing behavior, and completion sources.";
|
||||
"Section" = "Section";
|
||||
"Basics" = "Basics";
|
||||
"Behavior" = "Behavior";
|
||||
"Layout" = "Layout";
|
||||
"Left" = "Left";
|
||||
"Right" = "Right";
|
||||
"Editor Basics" = "Editor Basics";
|
||||
"Editor Behavior" = "Editor Behavior";
|
||||
"Performance" = "Performance";
|
||||
"Balanced" = "Balanced";
|
||||
"Large Files" = "Large Files";
|
||||
"Battery" = "Battery";
|
||||
"Balanced keeps default behavior. Large Files and Battery enter performance mode earlier." = "Balanced keeps default behavior. Large Files and Battery enter performance mode earlier.";
|
||||
"Off" = "Off";
|
||||
"Minimal" = "Minimal";
|
||||
"Standard" = "Standard";
|
||||
"Deferred" = "Deferred";
|
||||
"Plain Text" = "Plain Text";
|
||||
"Minimal colors only visible JSON lines plus a small buffer using a strict work budget." = "Minimal colors only visible JSON lines plus a small buffer using a strict work budget.";
|
||||
"Deferred uses a lightweight loading step and chunked editor install. Plain Text keeps large-file sessions unstyled." = "Deferred uses a lightweight loading step and chunked editor install. Plain Text keeps large-file sessions unstyled.";
|
||||
"Pick a preset or customize token colors for your editing environment." = "Pick a preset or customize token colors for your editing environment.";
|
||||
"Formatting" = "Formatting";
|
||||
"Bold keywords" = "Bold keywords";
|
||||
"Italic comments" = "Italic comments";
|
||||
"Underline links" = "Underline links";
|
||||
"Bold Markdown headings" = "Bold Markdown headings";
|
||||
"Custom theme applies immediately. Formatting applies to every active theme." = "Custom theme applies immediately. Formatting applies to every active theme.";
|
||||
"Select Custom to edit colors. Formatting applies to every active theme." = "Select Custom to edit colors. Formatting applies to every active theme.";
|
||||
"AI setup, provider credentials, and support options." = "AI setup, provider credentials, and support options.";
|
||||
"AI model, privacy disclosure, and provider credentials." = "AI model, privacy disclosure, and provider credentials.";
|
||||
"Safe local diagnostics for update and file-open troubleshooting." = "Safe local diagnostics for update and file-open troubleshooting.";
|
||||
"Updater" = "Updater";
|
||||
"Recent updater log" = "Recent updater log";
|
||||
"File Open Timing" = "File Open Timing";
|
||||
"No recent file-open snapshots yet." = "No recent file-open snapshots yet.";
|
||||
"Staged update: %@" = "Staged update: %@";
|
||||
"Last install attempt: %@" = "Last install attempt: %@";
|
||||
"Tip: Enable only one startup mode to keep app launch behavior predictable." = "Tip: Enable only one startup mode to keep app launch behavior predictable.";
|
||||
"When Line Wrap is enabled, scope guides/scoped region are turned off to avoid layout conflicts." = "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." = "Scope guides are intended for non-Swift languages. Swift favors matching-token highlight.";
|
||||
|
|
|
|||
|
|
@ -96,4 +96,58 @@ final class ReleaseRuntimePolicyTests: XCTestCase {
|
|||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testSafeModeStartupDecision() {
|
||||
XCTAssertFalse(
|
||||
ReleaseRuntimePolicy.shouldEnterSafeMode(
|
||||
consecutiveFailedLaunches: 0,
|
||||
requestedManually: false
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
ReleaseRuntimePolicy.shouldEnterSafeMode(
|
||||
consecutiveFailedLaunches: 1,
|
||||
requestedManually: false
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
ReleaseRuntimePolicy.shouldEnterSafeMode(
|
||||
consecutiveFailedLaunches: ReleaseRuntimePolicy.safeModeFailureThreshold,
|
||||
requestedManually: false
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
ReleaseRuntimePolicy.shouldEnterSafeMode(
|
||||
consecutiveFailedLaunches: 0,
|
||||
requestedManually: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testSafeModeStartupMessageExplainsTrigger() {
|
||||
XCTAssertNil(
|
||||
ReleaseRuntimePolicy.safeModeStartupMessage(
|
||||
consecutiveFailedLaunches: 0,
|
||||
requestedManually: false
|
||||
)
|
||||
)
|
||||
|
||||
let automatic = ReleaseRuntimePolicy.safeModeStartupMessage(
|
||||
consecutiveFailedLaunches: 2,
|
||||
requestedManually: false
|
||||
)
|
||||
XCTAssertEqual(
|
||||
automatic,
|
||||
"Safe Mode is active because the last 2 launch attempts did not finish cleanly. Session restore and startup diagnostics are paused."
|
||||
)
|
||||
|
||||
let manual = ReleaseRuntimePolicy.safeModeStartupMessage(
|
||||
consecutiveFailedLaunches: 0,
|
||||
requestedManually: true
|
||||
)
|
||||
XCTAssertEqual(
|
||||
manual,
|
||||
"Safe Mode is active for this launch. Session restore and startup diagnostics are paused."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
31
README.md
31
README.md
|
|
@ -64,10 +64,10 @@
|
|||
|
||||
|
||||
> Status: **active release**
|
||||
> Latest release: **v0.5.5**
|
||||
> Latest release: **v0.5.6**
|
||||
> Platform target: **macOS 26 (Tahoe)** compatible with **macOS Sequoia**
|
||||
> Apple Silicon: tested / Intel: not tested
|
||||
> Last updated (README): **2026-03-17** for release line **v0.5.5**
|
||||
> Last updated (README): **2026-03-17** for release line **v0.5.6**
|
||||
|
||||
## Start Here
|
||||
|
||||
|
|
@ -167,7 +167,7 @@
|
|||
- Security policy: [`SECURITY.md`](SECURITY.md)
|
||||
- Release checklists: [`release/`](release/) — TestFlight & App Store preflight docs
|
||||
|
||||
## What's New Since v0.5.4
|
||||
## What's New Since v0.5.5
|
||||
|
||||
- Added a dedicated large-file open mode with deferred first paint and chunked text installation, so ultra-large files no longer depend on a single blocking initial render.
|
||||
- Added per-session large-file modes directly in the editor UI: `Standard`, `Deferred`, and `Plain Text`.
|
||||
|
|
@ -298,10 +298,10 @@ Platform-specific availability is tracked in the [Platform Matrix](#platform-mat
|
|||
## NEW FEATURE Spotlight
|
||||
|
||||
<p align="center">
|
||||
<img alt="New Feature Release" src="https://img.shields.io/badge/NEW%20FEATURE-v0.5.5-F97316?style=for-the-badge">
|
||||
<img alt="New Feature Release" src="https://img.shields.io/badge/NEW%20FEATURE-v0.5.6-F97316?style=for-the-badge">
|
||||
</p>
|
||||
|
||||
**Featured in v0.5.5:** Code Snapshot creation flow with toolbar + selection-context actions (`camera.viewfinder`) and a styled share/export composer.
|
||||
**Featured in v0.5.6:** Safe Mode startup recovery with repeated-failure detection, blank-document launch fallback, a dedicated startup explanation, and a `Normal Next Launch` recovery action.
|
||||
|
||||
Create polished share images directly from your selected code.
|
||||
|
||||
|
|
@ -495,12 +495,12 @@ Most editor features are shared across macOS, iOS, and iPadOS.
|
|||
## Roadmap (Near Term)
|
||||
|
||||
<p align="center">
|
||||
<img alt="Now" src="https://img.shields.io/badge/NOW-v0.5.3%20to%20v0.5.5-22C55E?style=for-the-badge">
|
||||
<img alt="Next" src="https://img.shields.io/badge/NEXT-v0.5.6%20to%20v0.5.8-F59E0B?style=for-the-badge">
|
||||
<img alt="Now" src="https://img.shields.io/badge/NOW-v0.5.4%20to%20v0.5.6-22C55E?style=for-the-badge">
|
||||
<img alt="Next" src="https://img.shields.io/badge/NEXT-v0.5.7%20to%20v0.5.9-F59E0B?style=for-the-badge">
|
||||
<img alt="Later" src="https://img.shields.io/badge/LATER-v0.6.0-0A84FF?style=for-the-badge">
|
||||
</p>
|
||||
|
||||
### Now (v0.5.3 - v0.5.5)
|
||||
### Now (v0.5.4 - v0.5.6)
|
||||
|
||||
-  indexed project search and Open Recent favorites.
|
||||
Tracking: [Milestone 0.5.3](https://github.com/h3pdesign/Neon-Vision-Editor/milestone/4) · [#29](https://github.com/h3pdesign/Neon-Vision-Editor/issues/29) · [#31](https://github.com/h3pdesign/Neon-Vision-Editor/issues/31)
|
||||
|
|
@ -509,7 +509,7 @@ Most editor features are shared across macOS, iOS, and iPadOS.
|
|||
-  first-open/sidebar rendering stabilization, session-restore hardening, and Code Snapshot workflow polish.
|
||||
Tracking: [Milestone 0.5.5](https://github.com/h3pdesign/Neon-Vision-Editor/milestone/6) · [Release v0.5.5](https://github.com/h3pdesign/Neon-Vision-Editor/releases/tag/v0.5.5)
|
||||
|
||||
### Next (v0.5.6 - v0.5.8)
|
||||
### Next (v0.5.7 - v0.5.9)
|
||||
|
||||
-  Safe Mode startup.
|
||||
Tracking: [#27](https://github.com/h3pdesign/Neon-Vision-Editor/issues/27)
|
||||
|
|
@ -615,18 +615,19 @@ All shortcuts use `Cmd` (`⌘`). iPad/iOS require a hardware keyboard.
|
|||
|
||||
## Changelog
|
||||
|
||||
Latest stable: **v0.5.5** (2026-03-16)
|
||||
Latest stable: **v0.5.6** (2026-03-17)
|
||||
|
||||
### Recent Releases (At a glance)
|
||||
|
||||
| Version | Date | Highlights | Fixes | Breaking changes | Migration |
|
||||
|---|---|---|---|---|---|
|
||||
| [`v0.5.5`](https://github.com/h3pdesign/Neon-Vision-Editor/releases/tag/v0.5.5) | 2026-03-16 | Stabilized first-open rendering from the project sidebar so file content and syntax highlighting appear on first click without requiring tab switches; Hardened startup/session behavior so `Reopen Last Session` reliably wins over conflicting blank-document startup states; Refined large-file activation and loading placeholders to avoid misclassifying smaller files as large-file sessions; Code Snapshot creation flow with toolbar + selection-context actions (`camera.viewfinder`) and a styled share/export composer | a session-restore regression where previously open files could appear empty on first sidebar click until changing tabs; highlight scheduling during document-state transitions (`switch`, `finish load`, external edits) on macOS, iOS, and iPadOS; startup-default conflicts by aligning defaults and runtime startup gating between `Reopen Last Session` and `Open with Blank Document` | None noted | None required |
|
||||
| [`v0.5.6`](https://github.com/h3pdesign/Neon-Vision-Editor/releases/tag/v0.5.6) | 2026-03-17 | Safe Mode startup recovery with repeated-failure detection, blank-document launch fallback, a dedicated startup explanation, and a `Normal Next Launch` recovery action; a background project file index for larger folders and wired it into `Quick Open`, `Find in Files`, and project refresh flows; an iPad hardware-keyboard Vim MVP with core normal-mode navigation/editing commands and shared mode-state reporting; theme formatting controls for bold keywords, italic comments, underlined links, and bold Markdown headings across active themes | Safe Mode so a successful launch clears recovery state and normal restarts no longer re-enter Safe Mode unnecessarily; theme-formatting updates so editor styling refreshes immediately without requiring a theme switch; the editor font-size regression introduced by theme-formatting changes by restoring the base font before applying emphasis overrides | None noted | None required |
|
||||
| [`v0.5.5`](https://github.com/h3pdesign/Neon-Vision-Editor/releases/tag/v0.5.5) | 2026-03-16 | Stabilized first-open rendering from the project sidebar so file content and syntax highlighting appear on first click without requiring tab switches; Hardened startup/session behavior so `Reopen Last Session` reliably wins over conflicting blank-document startup states; Refined large-file activation and loading placeholders to avoid misclassifying smaller files as large-file sessions; Share Shot (`Code Snapshot`) creation flow with toolbar + selection-context actions (`camera.viewfinder`) and a styled share/export composer | a session-restore regression where previously open files could appear empty on first sidebar click until changing tabs; highlight scheduling during document-state transitions (`switch`, `finish load`, external edits) on macOS, iOS, and iPadOS; startup-default conflicts by aligning defaults and runtime startup gating between `Reopen Last Session` and `Open with Blank Document` | None noted | None required |
|
||||
| [`v0.5.4`](https://github.com/h3pdesign/Neon-Vision-Editor/releases/tag/v0.5.4) | 2026-03-13 | a dedicated large-file open mode with deferred first paint, chunked text installation, and an optional plain-text session mode for ultra-large documents | large-file responsiveness regressions across project-sidebar reopen, tab switching, line-number visibility, status metrics, and large-file editor rendering stability | None noted | None required |
|
||||
| [`v0.5.3`](https://github.com/h3pdesign/Neon-Vision-Editor/releases/tag/v0.5.3) | 2026-03-10 | a new high-readability colorful light theme preset: `Prism Daylight` (also selectable while app appearance is set to dark); double-click-to-close behavior for tabs on macOS tab strips; custom theme vibrancy by applying the vivid neon syntax profile to `Custom`, so syntax colors remain bright and saturated | toolbar-symbol contrast edge cases in dark mode where gray/black variants could appear too similar | None noted | None required |
|
||||
|
||||
- Full release history: [`CHANGELOG.md`](CHANGELOG.md)
|
||||
- Compare recent changes: [v0.5.4...v0.5.5](https://github.com/h3pdesign/Neon-Vision-Editor/compare/v0.5.4...v0.5.5)
|
||||
- Latest release: **v0.5.6**
|
||||
- Compare recent changes: [v0.5.5...v0.5.6](https://github.com/h3pdesign/Neon-Vision-Editor/compare/v0.5.5...v0.5.6)
|
||||
|
||||
## Known Limitations
|
||||
|
||||
|
|
@ -645,12 +646,12 @@ Latest stable: **v0.5.5** (2026-03-16)
|
|||
|
||||
## Release Integrity
|
||||
|
||||
- Tag: `v0.5.5`
|
||||
- Tag: `v0.5.6`
|
||||
- Tagged commit: `f23c74a`
|
||||
- Verify local tag target:
|
||||
|
||||
```bash
|
||||
git rev-parse --verify v0.5.5
|
||||
git rev-parse --verify v0.5.6
|
||||
```
|
||||
|
||||
- Verify downloaded artifact checksum locally:
|
||||
|
|
|
|||
Loading…
Reference in a new issue