feat: prepare v0.5.6 milestone release

This commit is contained in:
h3p 2026-03-17 18:40:32 +01:00
parent dd0d8856c6
commit 41966cd06c
22 changed files with 3093 additions and 640 deletions

View file

@ -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
- ![v0.5.6 hero screenshot](docs/images/iphone-themes-light.png)
### 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

View file

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

View file

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

View 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
}
}

View file

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

View file

@ -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."
}
}

View file

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

View file

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

View file

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

View 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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&#39;")
}
}

View 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")
}
}

View file

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

View file

@ -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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&#39;")
}
#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

View file

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

View 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
}
}

View file

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

View file

@ -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: "Whats 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."
"![v0.5.6 hero screenshot](docs/images/iphone-themes-light.png)",
"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)],

View file

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

View file

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

View file

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

View file

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

View file

@ -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)
- ![v0.5.3](https://img.shields.io/badge/v0.5.3-22C55E?style=flat-square) 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.
- ![v0.5.5](https://img.shields.io/badge/v0.5.5-22C55E?style=flat-square) 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)
- ![v0.5.6](https://img.shields.io/badge/v0.5.6-F59E0B?style=flat-square) 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: