Prepare v0.4.7: scope highlighting and settings persistence fixes

This commit is contained in:
h3p 2026-02-12 23:20:39 +01:00
parent 2f5f7d1835
commit 5f8041480e
18 changed files with 1131 additions and 219 deletions

10
.githooks/pre-commit Executable file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"
if [[ -x "scripts/bump_build_number.sh" ]]; then
scripts/bump_build_number.sh
git add "Neon Vision Editor.xcodeproj/project.pbxproj"
fi

View file

@ -8,15 +8,46 @@ on:
description: "Existing Git tag to release (e.g. v0.4.6)"
required: true
type: string
use_self_hosted:
description: "Allow self-hosted runner usage (requires trusted release context)"
required: true
default: false
type: boolean
permissions:
actions: read
contents: write
jobs:
release:
if: ${{ inputs.use_self_hosted == true }}
runs-on: [self-hosted, macOS]
environment: self-hosted-release
concurrency:
group: release-notarized-selfhosted
cancel-in-progress: false
steps:
- name: Validate trusted self-hosted request
env:
TAG_NAME: ${{ inputs.tag }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git init
git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git"
git fetch --depth=1 origin "refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}"
git fetch --depth=1 origin "refs/heads/main:refs/remotes/origin/main"
TAG_SHA="$(git rev-list -n1 "refs/tags/${TAG_NAME}")"
MAIN_SHA="$(git rev-list -n1 "refs/remotes/origin/main")"
echo "Tag SHA: ${TAG_SHA}"
echo "Main SHA: ${MAIN_SHA}"
if [[ "${TAG_SHA}" != "${MAIN_SHA}" ]]; then
echo "Self-hosted releases are only allowed for tags that point to origin/main HEAD." >&2
exit 1
fi
- name: Checkout tag
env:
TAG_NAME: ${{ inputs.tag }}

View file

@ -358,7 +358,7 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 175;
CURRENT_PROJECT_VERSION = 176;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES;
@ -438,7 +438,7 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 175;
CURRENT_PROJECT_VERSION = 176;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES;

View file

@ -5,6 +5,9 @@ import FoundationModels
#if os(macOS)
import AppKit
#endif
#if os(iOS)
import UIKit
#endif
#if os(macOS)
final class AppDelegate: NSObject, NSApplicationDelegate {
@ -73,6 +76,7 @@ private struct DetachedWindowContentView: View {
struct NeonVisionEditorApp: App {
@StateObject private var viewModel = EditorViewModel()
@StateObject private var supportPurchaseManager = SupportPurchaseManager()
@AppStorage("SettingsAppearance") private var appearance: String = "system"
#if os(macOS)
@Environment(\.openWindow) private var openWindow
@State private var useAppleIntelligence: Bool = true
@ -93,6 +97,66 @@ struct NeonVisionEditorApp: App {
}
#endif
private var preferredAppearance: ColorScheme? {
switch appearance {
case "light":
return .light
case "dark":
return .dark
default:
return nil
}
}
#if os(macOS)
private var appKitAppearance: NSAppearance? {
switch appearance {
case "light":
return NSAppearance(named: .aqua)
case "dark":
return NSAppearance(named: .darkAqua)
default:
return nil
}
}
private func applyGlobalAppearanceOverride() {
let override = appKitAppearance
NSApp.appearance = override
for window in NSApp.windows {
window.appearance = override
window.invalidateShadow()
window.displayIfNeeded()
}
}
#endif
#if os(iOS)
private var userInterfaceStyle: UIUserInterfaceStyle {
switch appearance {
case "light":
return .light
case "dark":
return .dark
default:
return .unspecified
}
}
private func applyIOSAppearanceOverride() {
let style = userInterfaceStyle
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.forEach { scene in
scene.windows.forEach { window in
if window.overrideUserInterfaceStyle != style {
window.overrideUserInterfaceStyle = style
}
}
}
}
#endif
init() {
let defaults = UserDefaults.standard
SecureTokenStore.migrateLegacyUserDefaultsTokens()
@ -108,8 +172,12 @@ struct NeonVisionEditorApp: App {
defaults.register(defaults: [
"SettingsShowLineNumbers": true,
"SettingsHighlightCurrentLine": false,
"SettingsHighlightMatchingBrackets": false,
"SettingsShowScopeGuides": false,
"SettingsHighlightScopeBackground": false,
"SettingsLineWrapEnabled": false,
"SettingsShowInvisibleCharacters": false,
"SettingsUseSystemFont": false,
"SettingsIndentStyle": "spaces",
"SettingsIndentWidth": 4,
"SettingsAutoIndent": true,
@ -118,7 +186,12 @@ struct NeonVisionEditorApp: App {
"SettingsTrimWhitespaceForSyntaxDetection": false,
"SettingsCompletionEnabled": false,
"SettingsCompletionFromDocument": false,
"SettingsCompletionFromSyntax": false
"SettingsCompletionFromSyntax": false,
"SettingsReopenLastSession": true,
"SettingsOpenWithBlankDocument": true,
"SettingsDefaultNewFileLanguage": "plain",
"SettingsConfirmCloseDirtyTab": true,
"SettingsConfirmClearEditor": true
])
let whitespaceMigrationKey = "SettingsMigrationWhitespaceGlyphResetV1"
if !defaults.bool(forKey: whitespaceMigrationKey) {
@ -158,8 +231,11 @@ struct NeonVisionEditorApp: App {
.environmentObject(viewModel)
.environmentObject(supportPurchaseManager)
.onAppear { appDelegate.viewModel = viewModel }
.onAppear { applyGlobalAppearanceOverride() }
.onChange(of: appearance) { _, _ in applyGlobalAppearanceOverride() }
.environment(\.showGrokError, $showGrokError)
.environment(\.grokErrorMessage, $grokErrorMessage)
.preferredColorScheme(preferredAppearance)
.frame(minWidth: 600, minHeight: 400)
.task {
#if USE_FOUNDATION_MODELS && canImport(FoundationModels)
@ -187,22 +263,25 @@ struct NeonVisionEditorApp: App {
showGrokError: $showGrokError,
grokErrorMessage: $grokErrorMessage
)
.onAppear { applyGlobalAppearanceOverride() }
.onChange(of: appearance) { _, _ in applyGlobalAppearanceOverride() }
.preferredColorScheme(preferredAppearance)
}
.defaultSize(width: 1000, height: 600)
.handlesExternalEvents(matching: [])
WindowGroup("Settings", id: "settings") {
Settings {
NeonSettingsView()
.environmentObject(supportPurchaseManager)
.background(NonRestorableWindow())
.onAppear { applyGlobalAppearanceOverride() }
.onChange(of: appearance) { _, _ in applyGlobalAppearanceOverride() }
.preferredColorScheme(preferredAppearance)
}
.defaultSize(width: 860, height: 620)
.commands {
CommandGroup(replacing: .appSettings) {
Button("Settings…") {
openWindow(id: "settings")
showSettingsWindow()
}
.keyboardShortcut(",", modifiers: .command)
}
@ -308,24 +387,6 @@ struct NeonVisionEditorApp: App {
Button("API Settings…") {
postWindowCommand(.showAPISettingsRequested)
}
Divider()
Button("Use Apple Intelligence") {
postWindowCommand(.selectAIModelRequested, object: AIModel.appleIntelligence.rawValue)
}
Button("Use Grok") {
postWindowCommand(.selectAIModelRequested, object: AIModel.grok.rawValue)
}
Button("Use OpenAI") {
postWindowCommand(.selectAIModelRequested, object: AIModel.openAI.rawValue)
}
Button("Use Gemini") {
postWindowCommand(.selectAIModelRequested, object: AIModel.gemini.rawValue)
}
Button("Use Anthropic") {
postWindowCommand(.selectAIModelRequested, object: AIModel.anthropic.rawValue)
}
}
CommandGroup(after: .toolbar) {
@ -464,41 +525,22 @@ struct NeonVisionEditorApp: App {
.environmentObject(supportPurchaseManager)
.environment(\.showGrokError, $showGrokError)
.environment(\.grokErrorMessage, $grokErrorMessage)
.onAppear { applyIOSAppearanceOverride() }
.onChange(of: appearance) { _, _ in applyIOSAppearanceOverride() }
.preferredColorScheme(preferredAppearance)
}
#endif
}
private func showSettingsWindow() {
#if os(macOS)
openWindow(id: "settings")
if !NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) {
_ = NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
}
#endif
}
}
#if os(macOS)
private struct NonRestorableWindow: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
if let window = view.window {
window.isRestorable = false
window.identifier = nil
}
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
DispatchQueue.main.async {
if let window = nsView.window {
window.isRestorable = false
window.identifier = nil
}
}
}
}
#endif
struct ShowGrokErrorKey: EnvironmentKey {
static let defaultValue: Binding<Bool> = .constant(false)
}

View file

@ -53,7 +53,21 @@ struct SyntaxColors {
// Regex patterns per language mapped to colors. Keep light-weight for performance.
func getSyntaxPatterns(for language: String, colors: SyntaxColors) -> [String: Color] {
switch language {
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"
default:
canonical = normalized
}
switch canonical {
case "swift":
return [
// Keywords (extended to include `import`)

View file

@ -142,7 +142,7 @@ class EditorViewModel: ObservableObject {
func addNewTab() {
// Keep language discovery active for new untitled tabs.
let newTab = TabData(name: "Untitled \(tabs.count + 1)", content: "", language: "plain", fileURL: nil, languageLocked: false)
let newTab = TabData(name: "Untitled \(tabs.count + 1)", content: "", language: defaultNewTabLanguage(), fileURL: nil, languageLocked: false)
tabs.append(newTab)
selectedTabID = newTab.id
}
@ -438,4 +438,10 @@ class EditorViewModel: ObservableObject {
print(message)
#endif
}
private func defaultNewTabLanguage() -> String {
let stored = UserDefaults.standard.string(forKey: "SettingsDefaultNewFileLanguage") ?? "plain"
let trimmed = stored.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed.isEmpty ? "plain" : trimmed
}
}

View file

@ -40,7 +40,7 @@ final class SupportPurchaseManager: ObservableObject {
}
var supportPriceLabel: String {
supportProduct?.displayPrice ?? "EUR 4.90"
supportProduct?.displayPrice ?? "$4.99"
}
var canBypassInCurrentBuild: Bool {

View file

@ -5,7 +5,7 @@
],
"products" : [
{
"displayPrice" : "4.90",
"displayPrice" : "4.99",
"familyShareable" : false,
"internalID" : "0D5E32E6-73E8-4DA0-9AE8-4C5A79EA9A20",
"localizations" : [

View file

@ -101,6 +101,15 @@ extension ContentView {
caretStatus = "Ln 1, Col 1"
}
func requestClearEditorContent() {
let hasText = !currentContentBinding.wrappedValue.isEmpty
if confirmClearEditor && hasText {
showClearEditorConfirmDialog = true
} else {
clearEditorContent()
}
}
func toggleSidebarFromToolbar() {
#if os(iOS)
if horizontalSizeClass == .compact {
@ -112,7 +121,7 @@ extension ContentView {
}
func requestCloseTab(_ tab: TabData) {
if tab.isDirty {
if tab.isDirty && confirmCloseDirtyTab {
pendingCloseTabID = tab.id
showUnsavedCloseDialog = true
} else {
@ -188,7 +197,48 @@ extension ContentView {
}
}
#else
findStatusMessage = "Find next is currently available on macOS editor."
guard !findQuery.isEmpty else { return }
findStatusMessage = ""
let source = currentContentBinding.wrappedValue
let ns = source as NSString
let fingerprint = "\(findQuery)|\(findUsesRegex)|\(findCaseSensitive)"
if fingerprint != iOSLastFindFingerprint {
iOSLastFindFingerprint = fingerprint
iOSFindCursorLocation = 0
}
let clampedStart = min(max(0, iOSFindCursorLocation), ns.length)
let forwardRange = NSRange(location: clampedStart, length: max(0, ns.length - clampedStart))
let wrapRange = NSRange(location: 0, length: max(0, clampedStart))
let foundRange: NSRange?
if findUsesRegex {
guard let regex = try? NSRegularExpression(pattern: findQuery, options: findCaseSensitive ? [] : [.caseInsensitive]) else {
findStatusMessage = "Invalid regex pattern"
return
}
foundRange = regex.firstMatch(in: source, options: [], range: forwardRange)?.range
?? regex.firstMatch(in: source, options: [], range: wrapRange)?.range
} else {
let opts: NSString.CompareOptions = findCaseSensitive ? [] : [.caseInsensitive]
foundRange = ns.range(of: findQuery, options: opts, range: forwardRange).toOptional()
?? ns.range(of: findQuery, options: opts, range: wrapRange).toOptional()
}
guard let match = foundRange else {
findStatusMessage = "No matches found"
return
}
iOSFindCursorLocation = match.upperBound
NotificationCenter.default.post(
name: .moveCursorToRange,
object: nil,
userInfo: [
EditorCommandUserInfo.rangeLocation: match.location,
EditorCommandUserInfo.rangeLength: match.length
]
)
#endif
}

View file

@ -92,58 +92,6 @@ extension ContentView {
.frame(width: isIPadToolbarLayout ? 160 : 120)
}
@ViewBuilder
private var aiSelectorControl: some View {
Button(action: {
showAISelectorPopover.toggle()
}) {
Image(systemName: "brain.head.profile")
}
.help("AI Model & Settings")
.popover(isPresented: $showAISelectorPopover) {
VStack(alignment: .leading, spacing: 8) {
Text("AI Model").font(.headline)
Picker("AI Model", selection: $selectedModel) {
HStack(spacing: 6) {
Image(systemName: "brain.head.profile")
Text("Apple Intelligence")
}
.tag(AIModel.appleIntelligence)
Text("Grok").tag(AIModel.grok)
Text("OpenAI").tag(AIModel.openAI)
Text("Gemini").tag(AIModel.gemini)
Text("Anthropic").tag(AIModel.anthropic)
}
.labelsHidden()
.frame(width: 170)
.controlSize(.large)
Button("API Settings…") {
showAISelectorPopover = false
openAPISettings()
}
.buttonStyle(.bordered)
}
.padding(12)
}
}
@ViewBuilder
private var aiSelectorMenuControl: some View {
Menu {
Button("Apple Intelligence") { selectedModel = .appleIntelligence }
Button("Grok") { selectedModel = .grok }
Button("OpenAI") { selectedModel = .openAI }
Button("Gemini") { selectedModel = .gemini }
Button("Anthropic") { selectedModel = .anthropic }
Divider()
Button("API Settings…") { openAPISettings() }
} label: {
Image(systemName: "brain.head.profile")
}
.help("AI Model & Settings")
}
@ViewBuilder
private var activeProviderBadgeControl: some View {
Text(compactActiveProviderName)
@ -162,7 +110,7 @@ extension ContentView {
@ViewBuilder
private var clearEditorControl: some View {
Button(action: {
clearEditorContent()
requestClearEditorContent()
}) {
Image(systemName: "trash")
}
@ -280,7 +228,6 @@ extension ContentView {
private var iOSToolbarControls: some View {
languagePickerControl
newTabControl
aiSelectorControl
activeProviderBadgeControl
clearEditorControl
settingsControl
@ -289,7 +236,6 @@ extension ContentView {
@ViewBuilder
private var iPadDistributedToolbarControls: some View {
aiSelectorMenuControl
activeProviderBadgeControl
languagePickerControl
newTabControl
@ -365,39 +311,6 @@ extension ContentView {
.frame(width: 140)
.padding(.vertical, 2)
Button(action: {
showAISelectorPopover.toggle()
}) {
Image(systemName: "brain.head.profile")
}
.help("AI Model & Settings")
.popover(isPresented: $showAISelectorPopover) {
VStack(alignment: .leading, spacing: 8) {
Text("AI Model").font(.headline)
Picker("AI Model", selection: $selectedModel) {
HStack(spacing: 6) {
Image(systemName: "brain.head.profile")
Text("Apple Intelligence")
}
.tag(AIModel.appleIntelligence)
Text("Grok").tag(AIModel.grok)
Text("OpenAI").tag(AIModel.openAI)
Text("Gemini").tag(AIModel.gemini)
Text("Anthropic").tag(AIModel.anthropic)
}
.labelsHidden()
.frame(width: 170)
.controlSize(.large)
Button("API Settings…") {
showAISelectorPopover = false
openAPISettings()
}
.buttonStyle(.bordered)
}
.padding(12)
}
Text(compactActiveProviderName)
.font(.caption)
.foregroundColor(.secondary)
@ -428,7 +341,7 @@ extension ContentView {
.help("Increase Font Size")
Button(action: {
clearEditorContent()
requestClearEditorContent()
}) {
Image(systemName: "trash")
}

View file

@ -44,7 +44,7 @@ struct ContentView: View {
@Environment(\.grokErrorMessage) var grokErrorMessage
// Single-document fallback state (used when no tab model is selected)
@State var selectedModel: AIModel = .appleIntelligence
@AppStorage("SelectedAIModel") private var selectedModelRaw: String = AIModel.appleIntelligence.rawValue
@State var singleContent: String = ""
@State var singleLanguage: String = "plain"
@State var caretStatus: String = "Ln 1, Col 1"
@ -53,6 +53,9 @@ struct ContentView: View {
@AppStorage("SettingsLineHeight") var editorLineHeight: Double = 1.0
@AppStorage("SettingsShowLineNumbers") var showLineNumbers: Bool = true
@AppStorage("SettingsHighlightCurrentLine") var highlightCurrentLine: Bool = false
@AppStorage("SettingsHighlightMatchingBrackets") var highlightMatchingBrackets: Bool = false
@AppStorage("SettingsShowScopeGuides") var showScopeGuides: Bool = false
@AppStorage("SettingsHighlightScopeBackground") var highlightScopeBackground: Bool = false
@AppStorage("SettingsLineWrapEnabled") var settingsLineWrapEnabled: Bool = false
// Removed showHorizontalRuler and showVerticalRuler AppStorage properties
@AppStorage("SettingsIndentStyle") var indentStyle: String = "spaces"
@ -63,6 +66,10 @@ struct ContentView: View {
@AppStorage("SettingsCompletionEnabled") var isAutoCompletionEnabled: Bool = false
@AppStorage("SettingsCompletionFromDocument") var completionFromDocument: Bool = false
@AppStorage("SettingsCompletionFromSyntax") var completionFromSyntax: Bool = false
@AppStorage("SettingsReopenLastSession") var reopenLastSession: Bool = true
@AppStorage("SettingsOpenWithBlankDocument") var openWithBlankDocument: Bool = true
@AppStorage("SettingsConfirmCloseDirtyTab") var confirmCloseDirtyTab: Bool = true
@AppStorage("SettingsConfirmClearEditor") var confirmClearEditor: Bool = true
@AppStorage("SettingsActiveTab") var settingsActiveTab: String = "general"
@AppStorage("SettingsTemplateLanguage") private var settingsTemplateLanguage: String = "swift"
@AppStorage("SettingsThemeName") private var settingsThemeName: String = "Neon Glow"
@ -80,9 +87,6 @@ struct ContentView: View {
@State private var isApplyingCompletion: Bool = false
@State var enableTranslucentWindow: Bool = UserDefaults.standard.bool(forKey: "EnableTranslucentWindow")
// Added missing popover UI state
@State var showAISelectorPopover: Bool = false
@State var showFindReplace: Bool = false
@State var showSettingsSheet: Bool = false
@State var findQuery: String = ""
@ -90,6 +94,8 @@ struct ContentView: View {
@State var findUsesRegex: Bool = false
@State var findCaseSensitive: Bool = false
@State var findStatusMessage: String = ""
@State var iOSFindCursorLocation: Int = 0
@State var iOSLastFindFingerprint: String = ""
@State var showProjectStructureSidebar: Bool = false
@State var showCompactSidebarSheet: Bool = false
@State var projectRootFolderURL: URL? = nil
@ -98,6 +104,7 @@ struct ContentView: View {
@State var projectFolderSecurityURL: URL? = nil
@State var pendingCloseTabID: UUID? = nil
@State var showUnsavedCloseDialog: Bool = false
@State var showClearEditorConfirmDialog: Bool = false
@State var showIOSFileImporter: Bool = false
@State var showIOSFileExporter: Bool = false
@State var iosExportDocument: PlainTextDocument = PlainTextDocument(text: "")
@ -122,6 +129,7 @@ struct ContentView: View {
@State private var languagePromptSelection: String = "plain"
@State private var languagePromptInsertTemplate: Bool = false
@State private var whitespaceInspectorMessage: String? = nil
@State private var didApplyStartupBehavior: Bool = false
#if USE_FOUNDATION_MODELS && canImport(FoundationModels)
var appleModelAvailable: Bool { true }
@ -131,6 +139,11 @@ struct ContentView: View {
var activeProviderName: String { lastProviderUsed }
var selectedModel: AIModel {
get { AIModel(rawValue: selectedModelRaw) ?? .appleIntelligence }
set { selectedModelRaw = newValue.rawValue }
}
/// Prompts the user for a Grok token if none is saved. Persists to Keychain.
/// Returns true if a token is present/was saved; false if cancelled or empty.
private func promptForGrokTokenIfNeeded() -> Bool {
@ -942,7 +955,7 @@ struct ContentView: View {
let viewWithEditorActions = view
.onReceive(NotificationCenter.default.publisher(for: .clearEditorRequested)) { notif in
guard matchesCurrentWindow(notif) else { return }
clearEditorContent()
requestClearEditorContent()
}
.onChange(of: isAutoCompletionEnabled) { _, enabled in
if enabled && viewModel.isBrainDumpMode {
@ -1002,14 +1015,13 @@ struct ContentView: View {
}
.onReceive(NotificationCenter.default.publisher(for: .showAPISettingsRequested)) { notif in
guard matchesCurrentWindow(notif) else { return }
showAISelectorPopover = false
openAPISettings()
}
.onReceive(NotificationCenter.default.publisher(for: .selectAIModelRequested)) { notif in
guard matchesCurrentWindow(notif) else { return }
guard let modelRawValue = notif.object as? String,
let model = AIModel(rawValue: modelRawValue) else { return }
selectedModel = model
selectedModelRaw = model.rawValue
}
return viewWithPanels
@ -1118,6 +1130,21 @@ struct ContentView: View {
.onChange(of: settingsThemeName) { _, _ in
highlightRefreshToken += 1
}
.onChange(of: highlightMatchingBrackets) { _, _ in
highlightRefreshToken += 1
}
.onChange(of: showScopeGuides) { _, _ in
highlightRefreshToken += 1
}
.onChange(of: highlightScopeBackground) { _, _ in
highlightRefreshToken += 1
}
.onChange(of: viewModel.isLineWrapEnabled) { _, _ in
highlightRefreshToken += 1
}
.onReceive(viewModel.$tabs) { _ in
persistSessionIfReady()
}
.sheet(isPresented: $showFindReplace) {
FindReplacePanel(
findQuery: $findQuery,
@ -1131,6 +1158,11 @@ struct ContentView: View {
)
#if canImport(UIKit)
.frame(maxWidth: 420)
#if os(iOS)
.presentationDetents([.height(280), .medium])
.presentationDragIndicator(.visible)
.presentationContentInteraction(.scrolls)
#endif
#else
.frame(width: 420)
#endif
@ -1142,6 +1174,11 @@ struct ContentView: View {
supportsTranslucency: false
)
.environmentObject(supportPurchaseManager)
#if os(iOS)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
.presentationContentInteraction(.scrolls)
#endif
}
#endif
#if os(iOS)
@ -1202,6 +1239,12 @@ struct ContentView: View {
Text("This file has unsaved changes.")
}
}
.confirmationDialog("Clear editor content?", isPresented: $showClearEditorConfirmDialog, titleVisibility: .visible) {
Button("Clear", role: .destructive) { clearEditorContent() }
Button("Cancel", role: .cancel) {}
} message: {
Text("This will remove all text in the current editor.")
}
#if canImport(UIKit)
.fileImporter(
isPresented: $showIOSFileImporter,
@ -1224,6 +1267,8 @@ struct ContentView: View {
viewModel.showSidebar = false
showProjectStructureSidebar = false
applyStartupBehaviorIfNeeded()
// Restore Brain Dump mode from defaults
if UserDefaults.standard.object(forKey: "BrainDumpModeEnabled") != nil {
viewModel.isBrainDumpMode = UserDefaults.standard.bool(forKey: "BrainDumpModeEnabled")
@ -1260,6 +1305,55 @@ struct ContentView: View {
#endif
}
private func applyStartupBehaviorIfNeeded() {
guard !didApplyStartupBehavior else { return }
if viewModel.tabs.contains(where: { $0.fileURL != nil }) {
didApplyStartupBehavior = true
persistSessionIfReady()
return
}
if openWithBlankDocument {
didApplyStartupBehavior = true
persistSessionIfReady()
return
}
if reopenLastSession {
let paths = UserDefaults.standard.stringArray(forKey: "LastSessionFileURLs") ?? []
let selectedPath = UserDefaults.standard.string(forKey: "LastSessionSelectedFileURL")
let urls = paths.compactMap { URL(string: $0) }
if !urls.isEmpty {
viewModel.tabs.removeAll()
viewModel.selectedTabID = nil
for url in urls {
viewModel.openFile(url: url)
}
if let selectedPath, let selectedURL = URL(string: selectedPath) {
_ = viewModel.focusTabIfOpen(for: selectedURL)
}
if viewModel.tabs.isEmpty {
viewModel.addNewTab()
}
}
}
didApplyStartupBehavior = true
persistSessionIfReady()
}
private func persistSessionIfReady() {
guard didApplyStartupBehavior else { return }
let urls = viewModel.tabs.compactMap { $0.fileURL?.absoluteString }
UserDefaults.standard.set(urls, forKey: "LastSessionFileURLs")
UserDefaults.standard.set(viewModel.selectedTab?.fileURL?.absoluteString, forKey: "LastSessionSelectedFileURL")
}
// Sidebar shows a lightweight table of contents (TOC) derived from the current document.
@ViewBuilder
var sidebarView: some View {
@ -1710,6 +1804,9 @@ struct ContentView: View {
showLineNumbers: showLineNumbers,
showInvisibleCharacters: false,
highlightCurrentLine: highlightCurrentLine,
highlightMatchingBrackets: highlightMatchingBrackets,
showScopeGuides: showScopeGuides,
highlightScopeBackground: highlightScopeBackground,
indentStyle: indentStyle,
indentWidth: indentWidth,
autoIndentEnabled: autoIndentEnabled,

View file

@ -5,6 +5,329 @@ extension Notification.Name {
static let pastedFileURL = Notification.Name("pastedFileURL")
}
private struct BracketScopeMatch {
let openRange: NSRange
let closeRange: NSRange
let scopeRange: NSRange?
let guideMarkerRanges: [NSRange]
}
private struct IndentationScopeMatch {
let scopeRange: NSRange
let guideMarkerRanges: [NSRange]
}
private func matchingOpeningBracket(for closing: unichar) -> unichar? {
switch UnicodeScalar(closing) {
case "}": return unichar(UnicodeScalar("{").value)
case "]": return unichar(UnicodeScalar("[").value)
case ")": return unichar(UnicodeScalar("(").value)
default: return nil
}
}
private func matchingClosingBracket(for opening: unichar) -> unichar? {
switch UnicodeScalar(opening) {
case "{": return unichar(UnicodeScalar("}").value)
case "[": return unichar(UnicodeScalar("]").value)
case "(": return unichar(UnicodeScalar(")").value)
default: return nil
}
}
private func isBracket(_ c: unichar) -> Bool {
matchesAny(c, ["{", "}", "[", "]", "(", ")"])
}
private func matchesAny(_ c: unichar, _ chars: [Character]) -> Bool {
guard let scalar = UnicodeScalar(c) else { return false }
return chars.contains(Character(scalar))
}
private func computeBracketScopeMatch(text: String, caretLocation: Int) -> BracketScopeMatch? {
let ns = text as NSString
let length = ns.length
guard length > 0 else { return nil }
func matchFrom(start: Int) -> BracketScopeMatch? {
guard start >= 0 && start < length else { return nil }
let startChar = ns.character(at: start)
let openIndex: Int
let closeIndex: Int
if let wantedClose = matchingClosingBracket(for: startChar) {
var depth = 0
var found: Int?
for i in start..<length {
let c = ns.character(at: i)
if c == startChar { depth += 1 }
if c == wantedClose {
depth -= 1
if depth == 0 {
found = i
break
}
}
}
guard let found else { return nil }
openIndex = start
closeIndex = found
} else if let wantedOpen = matchingOpeningBracket(for: startChar) {
var depth = 0
var found: Int?
var i = start
while i >= 0 {
let c = ns.character(at: i)
if c == startChar { depth += 1 }
if c == wantedOpen {
depth -= 1
if depth == 0 {
found = i
break
}
}
i -= 1
}
guard let found else { return nil }
openIndex = found
closeIndex = start
} else {
return nil
}
let openRange = NSRange(location: openIndex, length: 1)
let closeRange = NSRange(location: closeIndex, length: 1)
let scopeLength = max(0, closeIndex - openIndex - 1)
let scopeRange: NSRange? = scopeLength > 0 ? NSRange(location: openIndex + 1, length: scopeLength) : nil
let openLineRange = ns.lineRange(for: NSRange(location: openIndex, length: 0))
let closeLineRange = ns.lineRange(for: NSRange(location: closeIndex, length: 0))
let column = openIndex - openLineRange.location
var markers: [NSRange] = []
var lineStart = openLineRange.location
while lineStart <= closeLineRange.location && lineStart < length {
let lineRange = ns.lineRange(for: NSRange(location: lineStart, length: 0))
let lineEndExcludingNewline = lineRange.location + max(0, lineRange.length - 1)
if lineEndExcludingNewline > lineRange.location {
let markerLoc = min(lineRange.location + column, lineEndExcludingNewline - 1)
if markerLoc >= lineRange.location && markerLoc < lineEndExcludingNewline {
markers.append(NSRange(location: markerLoc, length: 1))
}
}
let nextLineStart = lineRange.location + lineRange.length
if nextLineStart <= lineStart { break }
lineStart = nextLineStart
}
return BracketScopeMatch(
openRange: openRange,
closeRange: closeRange,
scopeRange: scopeRange,
guideMarkerRanges: markers
)
}
let safeCaret = max(0, min(caretLocation, length))
var probeIndices: [Int] = [safeCaret]
if safeCaret > 0 { probeIndices.append(safeCaret - 1) }
var candidateIndices: [Int] = []
var seenCandidates = Set<Int>()
func addCandidate(_ index: Int) {
guard index >= 0 && index < length else { return }
if seenCandidates.insert(index).inserted {
candidateIndices.append(index)
}
}
for idx in probeIndices where idx >= 0 && idx < length {
if isBracket(ns.character(at: idx)) {
addCandidate(idx)
}
}
// If caret is not directly on a bracket, find the nearest enclosing opening
// bracket whose matching close still contains the caret.
var stack: [Int] = []
if safeCaret > 0 {
for i in 0..<safeCaret {
let c = ns.character(at: i)
if matchingClosingBracket(for: c) != nil {
stack.append(i)
continue
}
if let wantedOpen = matchingOpeningBracket(for: c), let last = stack.last, ns.character(at: last) == wantedOpen {
stack.removeLast()
}
}
}
while let candidate = stack.popLast() {
let c = ns.character(at: candidate)
guard let wantedClose = matchingClosingBracket(for: c) else { continue }
var depth = 0
var foundClose: Int?
for i in candidate..<length {
let current = ns.character(at: i)
if current == c { depth += 1 }
if current == wantedClose {
depth -= 1
if depth == 0 {
foundClose = i
break
}
}
}
if let close = foundClose, safeCaret >= candidate && safeCaret <= close {
addCandidate(candidate)
}
}
// Add all brackets by nearest distance so we still find a valid scope even if
// early candidates are unmatched (e.g. bracket chars inside strings/comments).
let allBracketIndices = (0..<length).filter { isBracket(ns.character(at: $0)) }
let sortedByDistance = allBracketIndices.sorted { abs($0 - safeCaret) < abs($1 - safeCaret) }
for idx in sortedByDistance {
addCandidate(idx)
}
for candidate in candidateIndices {
if let match = matchFrom(start: candidate) {
return match
}
}
return nil
}
private func supportsIndentationScopes(language: String) -> Bool {
let lang = language.lowercased()
return lang == "python" || lang == "yaml" || lang == "yml"
}
private func computeIndentationScopeMatch(text: String, caretLocation: Int) -> IndentationScopeMatch? {
let ns = text as NSString
let length = ns.length
guard length > 0 else { return nil }
struct LineInfo {
let range: NSRange
let contentEnd: Int
let indent: Int?
}
func lineIndent(_ lineRange: NSRange) -> Int? {
guard lineRange.length > 0 else { return nil }
let line = ns.substring(with: lineRange)
var indent = 0
var sawContent = false
for ch in line {
if ch == " " {
indent += 1
continue
}
if ch == "\t" {
indent += 4
continue
}
if ch == "\n" || ch == "\r" {
continue
}
sawContent = true
break
}
return sawContent ? indent : nil
}
var lines: [LineInfo] = []
var lineStart = 0
while lineStart < length {
let lr = ns.lineRange(for: NSRange(location: lineStart, length: 0))
let contentEnd = lr.location + max(0, lr.length - 1)
lines.append(LineInfo(range: lr, contentEnd: contentEnd, indent: lineIndent(lr)))
let next = lr.location + lr.length
if next <= lineStart { break }
lineStart = next
}
guard !lines.isEmpty else { return nil }
let safeCaret = max(0, min(caretLocation, max(0, length - 1)))
guard let caretLineIndex = lines.firstIndex(where: { NSLocationInRange(safeCaret, $0.range) }) else { return nil }
var blockStart = caretLineIndex
var baseIndent: Int? = lines[caretLineIndex].indent
// If caret is on a block header line (e.g. Python ":"), use the next indented line.
if baseIndent == nil || baseIndent == 0 {
let currentLine = ns.substring(with: lines[caretLineIndex].range).trimmingCharacters(in: .whitespacesAndNewlines)
if currentLine.hasSuffix(":") {
var next = caretLineIndex + 1
while next < lines.count {
if let nextIndent = lines[next].indent, nextIndent > 0 {
baseIndent = nextIndent
blockStart = next
break
}
next += 1
}
}
}
guard let indentLevel = baseIndent, indentLevel > 0 else { return nil }
var start = blockStart
while start > 0 {
let prev = lines[start - 1]
guard let prevIndent = prev.indent else {
start -= 1
continue
}
if prevIndent >= indentLevel {
start -= 1
continue
}
break
}
var end = blockStart
var idx = blockStart + 1
while idx < lines.count {
let info = lines[idx]
if let infoIndent = info.indent {
if infoIndent < indentLevel { break }
end = idx
idx += 1
continue
}
// Keep blank lines inside the current block.
end = idx
idx += 1
}
let startLoc = lines[start].range.location
let endLoc = lines[end].contentEnd
guard endLoc > startLoc else { return nil }
var guideMarkers: [NSRange] = []
for i in start...end {
let info = lines[i]
guard info.contentEnd > info.range.location else { continue }
guard let infoIndent = info.indent, infoIndent >= indentLevel else { continue }
let marker = min(info.range.location + max(0, indentLevel - 1), info.contentEnd - 1)
if marker >= info.range.location && marker < info.contentEnd {
guideMarkers.append(NSRange(location: marker, length: 1))
}
}
return IndentationScopeMatch(
scopeRange: NSRange(location: startLoc, length: endLoc - startLoc),
guideMarkerRanges: guideMarkers
)
}
private func isValidRange(_ range: NSRange, utf16Length: Int) -> Bool {
guard range.location != NSNotFound, range.length >= 0, range.location >= 0 else { return false }
return NSMaxRange(range) <= utf16Length
}
#if os(macOS)
import AppKit
@ -932,6 +1255,9 @@ struct CustomTextEditor: NSViewRepresentable {
let showLineNumbers: Bool
let showInvisibleCharacters: Bool
let highlightCurrentLine: Bool
let highlightMatchingBrackets: Bool
let showScopeGuides: Bool
let highlightScopeBackground: Bool
let indentStyle: String
let indentWidth: Int
let autoIndentEnabled: Bool
@ -942,6 +1268,10 @@ struct CustomTextEditor: NSViewRepresentable {
UserDefaults.standard.string(forKey: "SettingsEditorFontName") ?? ""
}
private var useSystemFont: Bool {
UserDefaults.standard.bool(forKey: "SettingsUseSystemFont")
}
private var lineHeightMultiple: CGFloat {
let stored = UserDefaults.standard.double(forKey: "SettingsLineHeight")
return CGFloat(stored > 0 ? stored : 1.0)
@ -977,6 +1307,9 @@ struct CustomTextEditor: NSViewRepresentable {
}
private func resolvedFont() -> NSFont {
if useSystemFont {
return NSFont.systemFont(ofSize: fontSize)
}
if let named = NSFont(name: fontName, size: fontSize) {
return named
}
@ -1054,7 +1387,7 @@ struct CustomTextEditor: NSViewRepresentable {
textView.usesFontPanel = false
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
textView.font = resolvedFont()
// Apply visibility preference from Settings (off by default).
applyInvisibleCharacterPreference(textView)
@ -1144,7 +1477,7 @@ struct CustomTextEditor: NSViewRepresentable {
guard let sv = scrollView, let tv = textView else { return }
sv.window?.makeFirstResponder(tv)
}
context.coordinator.scheduleHighlightIfNeeded(currentText: text)
context.coordinator.scheduleHighlightIfNeeded(currentText: text, immediate: true)
// Keep container width in sync when the scroll view resizes
NotificationCenter.default.addObserver(forName: NSView.boundsDidChangeNotification, object: scrollView.contentView, queue: .main) { [weak textView, weak scrollView] _ in
@ -1244,6 +1577,8 @@ struct CustomTextEditor: NSViewRepresentable {
.backgroundColor: NSColor(theme.selection)
]
let showLineNumbersByDefault = showLineNumbers
textView.usesRuler = showLineNumbersByDefault
textView.isRulerVisible = showLineNumbersByDefault
nsView.hasHorizontalRuler = false
nsView.horizontalRulerView = nil
nsView.hasVerticalRuler = showLineNumbersByDefault
@ -1321,6 +1656,9 @@ struct CustomTextEditor: NSViewRepresentable {
private var lastColorScheme: ColorScheme?
var lastLineHeight: CGFloat?
private var lastHighlightToken: Int = 0
private var lastSelectionLocation: Int = -1
private var isApplyingHighlight = false
private var highlightGeneration: Int = 0
init(_ parent: CustomTextEditor) {
self.parent = parent
@ -1338,11 +1676,12 @@ struct CustomTextEditor: NSViewRepresentable {
lastColorScheme = nil
lastLineHeight = nil
lastHighlightToken = 0
lastSelectionLocation = -1
}
/// Schedules highlighting if text/language/theme changed. Skips very large documents
/// and defers when a modal sheet is presented.
func scheduleHighlightIfNeeded(currentText: String? = nil) {
func scheduleHighlightIfNeeded(currentText: String? = nil, immediate: Bool = false) {
guard textView != nil else { return }
// Query NSApp.modalWindow on the main thread to avoid thread-check warnings
@ -1370,6 +1709,16 @@ struct CustomTextEditor: NSViewRepresentable {
let scheme = parent.colorScheme
let lineHeightValue: CGFloat = parent.lineHeightMultiple
let token = parent.highlightRefreshToken
let selectionLocation: Int = {
if Thread.isMainThread {
return textView?.selectedRange().location ?? 0
}
var result = 0
DispatchQueue.main.sync {
result = textView?.selectedRange().location ?? 0
}
return result
}()
let text: String = {
if let currentText = currentText {
return currentText
@ -1392,6 +1741,7 @@ struct CustomTextEditor: NSViewRepresentable {
self.lastColorScheme = scheme
self.lastLineHeight = lineHeightValue
self.lastHighlightToken = token
self.lastSelectionLocation = selectionLocation
return
}
@ -1404,14 +1754,22 @@ struct CustomTextEditor: NSViewRepresentable {
return
}
if text == lastHighlightedText && lastLanguage == lang && lastColorScheme == scheme && lastLineHeight == lineHeightValue && lastHighlightToken == token {
if text == lastHighlightedText &&
lastLanguage == lang &&
lastColorScheme == scheme &&
lastLineHeight == lineHeightValue &&
lastHighlightToken == token &&
lastSelectionLocation == selectionLocation {
return
}
rehighlight(token: token)
let shouldRunImmediate = immediate || lastHighlightedText.isEmpty || lastHighlightToken != token
highlightGeneration &+= 1
let generation = highlightGeneration
rehighlight(token: token, generation: generation, immediate: shouldRunImmediate)
}
/// Perform regex-based token coloring off-main, then apply attributes on the main thread.
func rehighlight(token: Int) {
func rehighlight(token: Int, generation: Int, immediate: Bool = false) {
guard let textView = textView else { return }
// Snapshot current state
let textSnapshot = textView.string
@ -1440,9 +1798,12 @@ struct CustomTextEditor: NSViewRepresentable {
DispatchQueue.main.async { [weak self] in
guard let self = self, let tv = self.textView else { return }
guard generation == self.highlightGeneration else { return }
// Discard if text changed since we started
guard tv.string == textSnapshot else { return }
let baseColor = self.parent.effectiveBaseTextColor()
self.isApplyingHighlight = true
defer { self.isApplyingHighlight = false }
tv.textStorage?.beginEditing()
// Clear previous coloring and apply base color
@ -1452,6 +1813,55 @@ struct CustomTextEditor: NSViewRepresentable {
for (range, color) in coloredRanges {
tv.textStorage?.addAttribute(.foregroundColor, value: NSColor(color), range: range)
}
let selectedLocation = min(max(0, selected.location), max(0, fullRange.length))
let wantsBracketTokens = self.parent.highlightMatchingBrackets
let wantsScopeBackground = self.parent.highlightScopeBackground
let wantsScopeGuides = self.parent.showScopeGuides && !self.parent.isLineWrapEnabled && self.parent.language.lowercased() != "swift"
let bracketMatch = computeBracketScopeMatch(text: textSnapshot, caretLocation: selectedLocation)
let indentationMatch: IndentationScopeMatch? = {
guard supportsIndentationScopes(language: self.parent.language) else { return nil }
return computeIndentationScopeMatch(text: textSnapshot, caretLocation: selectedLocation)
}()
if wantsBracketTokens, let match = bracketMatch {
let textLength = fullRange.length
let tokenColor = NSColor.systemOrange
if isValidRange(match.openRange, utf16Length: textLength) {
tv.textStorage?.addAttribute(.foregroundColor, value: tokenColor, range: match.openRange)
tv.textStorage?.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: match.openRange)
tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.systemOrange.withAlphaComponent(0.22), range: match.openRange)
}
if isValidRange(match.closeRange, utf16Length: textLength) {
tv.textStorage?.addAttribute(.foregroundColor, value: tokenColor, range: match.closeRange)
tv.textStorage?.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: match.closeRange)
tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.systemOrange.withAlphaComponent(0.22), range: match.closeRange)
}
}
if wantsScopeBackground || wantsScopeGuides {
let textLength = fullRange.length
let scopeRange = bracketMatch?.scopeRange ?? indentationMatch?.scopeRange
let guideRanges = bracketMatch?.guideMarkerRanges ?? indentationMatch?.guideMarkerRanges ?? []
if wantsScopeBackground, let scope = scopeRange, isValidRange(scope, utf16Length: textLength) {
tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.systemOrange.withAlphaComponent(0.18), range: scope)
}
if wantsScopeGuides {
for marker in guideRanges {
if isValidRange(marker, utf16Length: textLength) {
tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.systemBlue.withAlphaComponent(0.36), range: marker)
}
}
}
}
if self.parent.highlightCurrentLine {
let caret = NSRange(location: selectedLocation, length: 0)
let lineRange = nsText.lineRange(for: caret)
tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.selectedTextBackgroundColor.withAlphaComponent(0.12), range: lineRange)
}
tv.textStorage?.endEditing()
tv.typingAttributes[.foregroundColor] = baseColor
@ -1468,6 +1878,7 @@ struct CustomTextEditor: NSViewRepresentable {
self.lastColorScheme = scheme
self.lastLineHeight = lineHeightValue
self.lastHighlightToken = token
self.lastSelectionLocation = selectedLocation
// Re-apply visibility preference after recoloring.
self.parent.applyInvisibleCharacterPreference(tv)
@ -1475,8 +1886,12 @@ struct CustomTextEditor: NSViewRepresentable {
}
pendingHighlight = work
// Debounce slightly to avoid thrashing while typing
highlightQueue.asyncAfter(deadline: .now() + 0.12, execute: work)
// Run immediately on first paint/explicit refresh, debounce while typing.
if immediate {
highlightQueue.async(execute: work)
} else {
highlightQueue.asyncAfter(deadline: .now() + 0.12, execute: work)
}
}
func textDidChange(_ notification: Notification) {
@ -1525,6 +1940,7 @@ struct CustomTextEditor: NSViewRepresentable {
}
func textViewDidChangeSelection(_ notification: Notification) {
if isApplyingHighlight { return }
if let tv = notification.object as? AcceptingTextView {
tv.clearInlineSuggestion()
}
@ -1555,16 +1971,7 @@ struct CustomTextEditor: NSViewRepresentable {
}
}()
NotificationCenter.default.post(name: .caretPositionDidChange, object: nil, userInfo: ["line": line, "column": col])
// Highlight current line
let lineRange = ns.lineRange(for: NSRange(location: location, length: 0))
let fullRange = NSRange(location: 0, length: ns.length)
tv.textStorage?.beginEditing()
tv.textStorage?.removeAttribute(.backgroundColor, range: fullRange)
if parent.highlightCurrentLine {
tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.selectedTextBackgroundColor.withAlphaComponent(0.12), range: lineRange)
}
tv.textStorage?.endEditing()
scheduleHighlightIfNeeded(currentText: tv.string, immediate: true)
}
/// Move caret to a 1-based line number, clamping to bounds, and emphasize the line.
@ -1611,15 +2018,7 @@ struct CustomTextEditor: NSViewRepresentable {
tv.setSelectedRange(NSRange(location: location, length: 0))
tv.scrollRangeToVisible(NSRange(location: location, length: 0))
// Stronger highlight for the entire target line
let fullRange = NSRange(location: 0, length: totalLength)
tv.textStorage?.beginEditing()
tv.textStorage?.removeAttribute(.backgroundColor, range: fullRange)
if self.parent.highlightCurrentLine {
let lineRange = ns.lineRange(for: NSRange(location: location, length: 0))
tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.selectedTextBackgroundColor.withAlphaComponent(0.18), range: lineRange)
}
tv.textStorage?.endEditing()
self.scheduleHighlightIfNeeded(currentText: tv.string, immediate: true)
}
}
}
@ -1702,6 +2101,9 @@ struct CustomTextEditor: UIViewRepresentable {
let showLineNumbers: Bool
let showInvisibleCharacters: Bool
let highlightCurrentLine: Bool
let highlightMatchingBrackets: Bool
let showScopeGuides: Bool
let highlightScopeBackground: Bool
let indentStyle: String
let indentWidth: Int
let autoIndentEnabled: Bool
@ -1712,24 +2114,41 @@ struct CustomTextEditor: UIViewRepresentable {
UserDefaults.standard.string(forKey: "SettingsEditorFontName") ?? ""
}
private var useSystemFont: Bool {
UserDefaults.standard.bool(forKey: "SettingsUseSystemFont")
}
private var lineHeightMultiple: CGFloat {
let stored = UserDefaults.standard.double(forKey: "SettingsLineHeight")
return CGFloat(stored > 0 ? stored : 1.0)
}
private func resolvedUIFont(size: CGFloat? = nil) -> UIFont {
let targetSize = size ?? fontSize
if useSystemFont {
return UIFont.systemFont(ofSize: targetSize)
}
if let named = UIFont(name: fontName, size: targetSize) {
return named
}
return UIFont.monospacedSystemFont(ofSize: targetSize, weight: .regular)
}
func makeUIView(context: Context) -> LineNumberedTextViewContainer {
let container = LineNumberedTextViewContainer()
let textView = container.textView
textView.delegate = context.coordinator
if let named = UIFont(name: fontName, size: fontSize) {
textView.font = named
} else {
textView.font = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
}
let initialFont = resolvedUIFont()
textView.font = initialFont
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineHeightMultiple = max(0.9, lineHeightMultiple)
textView.typingAttributes[.paragraphStyle] = paragraphStyle
let baseColor: UIColor = colorScheme == .dark ? .white : .label
var typing = textView.typingAttributes
typing[.paragraphStyle] = paragraphStyle
typing[.foregroundColor] = baseColor
typing[.font] = textView.font ?? initialFont
textView.typingAttributes = typing
textView.text = text
if text.count <= 200_000 {
textView.textStorage.beginEditing()
@ -1745,7 +2164,7 @@ struct CustomTextEditor: UIViewRepresentable {
textView.textContainer.lineBreakMode = (isLineWrapEnabled && !isLargeFileMode) ? .byWordWrapping : .byClipping
textView.textContainer.widthTracksTextView = isLineWrapEnabled && !isLargeFileMode
if isLargeFileMode {
if isLargeFileMode || !showLineNumbers {
container.lineNumberView.isHidden = true
} else {
container.lineNumberView.isHidden = false
@ -1753,7 +2172,7 @@ struct CustomTextEditor: UIViewRepresentable {
}
context.coordinator.container = container
context.coordinator.textView = textView
context.coordinator.scheduleHighlightIfNeeded(currentText: text)
context.coordinator.scheduleHighlightIfNeeded(currentText: text, immediate: true)
return container
}
@ -1763,8 +2182,9 @@ struct CustomTextEditor: UIViewRepresentable {
if textView.text != text {
textView.text = text
}
if textView.font?.pointSize != fontSize {
textView.font = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
let targetFont = resolvedUIFont()
if textView.font?.fontName != targetFont.fontName || textView.font?.pointSize != targetFont.pointSize {
textView.font = targetFont
}
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineHeightMultiple = max(0.9, lineHeightMultiple)
@ -1779,12 +2199,11 @@ struct CustomTextEditor: UIViewRepresentable {
context.coordinator.lastLineHeight = lineHeightMultiple
}
let theme = currentEditorTheme(colorScheme: colorScheme)
textView.textColor = UIColor(theme.text)
textView.tintColor = UIColor(theme.cursor)
textView.backgroundColor = translucentBackgroundEnabled ? .clear : UIColor(theme.background)
textView.textContainer.lineBreakMode = (isLineWrapEnabled && !isLargeFileMode) ? .byWordWrapping : .byClipping
textView.textContainer.widthTracksTextView = isLineWrapEnabled && !isLargeFileMode
if isLargeFileMode {
if isLargeFileMode || !showLineNumbers {
uiView.lineNumberView.isHidden = true
} else {
uiView.lineNumberView.isHidden = false
@ -1809,19 +2228,40 @@ struct CustomTextEditor: UIViewRepresentable {
private var lastColorScheme: ColorScheme?
var lastLineHeight: CGFloat?
private var lastHighlightToken: Int = 0
private var lastSelectionLocation: Int = -1
private var isApplyingHighlight = false
private var highlightGeneration: Int = 0
init(_ parent: CustomTextEditor) {
self.parent = parent
super.init()
NotificationCenter.default.addObserver(self, selector: #selector(moveToRange(_:)), name: .moveCursorToRange, object: nil)
}
func scheduleHighlightIfNeeded(currentText: String? = nil) {
deinit {
NotificationCenter.default.removeObserver(self)
}
@objc private func moveToRange(_ notification: Notification) {
guard let textView else { return }
guard let location = notification.userInfo?[EditorCommandUserInfo.rangeLocation] as? Int,
let length = notification.userInfo?[EditorCommandUserInfo.rangeLength] as? Int else { return }
let textLength = (textView.text as NSString?)?.length ?? 0
guard location >= 0, length >= 0, location + length <= textLength else { return }
let range = NSRange(location: location, length: length)
textView.becomeFirstResponder()
textView.selectedRange = range
textView.scrollRangeToVisible(range)
}
func scheduleHighlightIfNeeded(currentText: String? = nil, immediate: Bool = false) {
guard let textView else { return }
let text = currentText ?? textView.text ?? ""
let lang = parent.language
let scheme = parent.colorScheme
let lineHeight = parent.lineHeightMultiple
let token = parent.highlightRefreshToken
let selectionLocation = textView.selectedRange.location
if parent.isLargeFileMode {
lastHighlightedText = text
@ -1829,26 +2269,45 @@ struct CustomTextEditor: UIViewRepresentable {
lastColorScheme = scheme
lastLineHeight = lineHeight
lastHighlightToken = token
lastSelectionLocation = selectionLocation
return
}
if text == lastHighlightedText && lang == lastLanguage && scheme == lastColorScheme && lineHeight == lastLineHeight && lastHighlightToken == token {
if text == lastHighlightedText &&
lang == lastLanguage &&
scheme == lastColorScheme &&
lineHeight == lastLineHeight &&
lastHighlightToken == token &&
lastSelectionLocation == selectionLocation {
return
}
pendingHighlight?.cancel()
highlightGeneration &+= 1
let generation = highlightGeneration
let work = DispatchWorkItem { [weak self] in
self?.rehighlight(text: text, language: lang, colorScheme: scheme, token: token)
self?.rehighlight(text: text, language: lang, colorScheme: scheme, token: token, generation: generation)
}
pendingHighlight = work
highlightQueue.asyncAfter(deadline: .now() + 0.1, execute: work)
if immediate || lastHighlightedText.isEmpty || lastHighlightToken != token {
highlightQueue.async(execute: work)
} else {
highlightQueue.asyncAfter(deadline: .now() + 0.1, execute: work)
}
}
private func rehighlight(text: String, language: String, colorScheme: ColorScheme, token: Int) {
private func rehighlight(text: String, language: String, colorScheme: ColorScheme, token: Int, generation: Int) {
let nsText = text as NSString
let fullRange = NSRange(location: 0, length: nsText.length)
let baseColor: UIColor = colorScheme == .dark ? .white : .label
let baseFont = UIFont.monospacedSystemFont(ofSize: parent.fontSize, weight: .regular)
let baseFont: UIFont
if parent.useSystemFont {
baseFont = UIFont.systemFont(ofSize: parent.fontSize)
} else if let named = UIFont(name: parent.fontName, size: parent.fontSize) {
baseFont = named
} else {
baseFont = UIFont.monospacedSystemFont(ofSize: parent.fontSize, weight: .regular)
}
let attributed = NSMutableAttributedString(
string: text,
@ -1872,10 +2331,50 @@ struct CustomTextEditor: UIViewRepresentable {
DispatchQueue.main.async { [weak self] in
guard let self, let textView = self.textView else { return }
guard generation == self.highlightGeneration else { return }
guard textView.text == text else { return }
let selectedRange = textView.selectedRange
self.isApplyingHighlight = true
textView.attributedText = attributed
let wantsBracketTokens = self.parent.highlightMatchingBrackets
let wantsScopeBackground = self.parent.highlightScopeBackground
let wantsScopeGuides = self.parent.showScopeGuides && !self.parent.isLineWrapEnabled && self.parent.language.lowercased() != "swift"
let bracketMatch = computeBracketScopeMatch(text: text, caretLocation: selectedRange.location)
let indentationMatch: IndentationScopeMatch? = {
guard supportsIndentationScopes(language: self.parent.language) else { return nil }
return computeIndentationScopeMatch(text: text, caretLocation: selectedRange.location)
}()
if wantsBracketTokens, let match = bracketMatch {
let textLength = fullRange.length
if isValidRange(match.openRange, utf16Length: textLength) {
textView.textStorage.addAttribute(.foregroundColor, value: UIColor.systemOrange, range: match.openRange)
textView.textStorage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: match.openRange)
textView.textStorage.addAttribute(.backgroundColor, value: UIColor.systemOrange.withAlphaComponent(0.22), range: match.openRange)
}
if isValidRange(match.closeRange, utf16Length: textLength) {
textView.textStorage.addAttribute(.foregroundColor, value: UIColor.systemOrange, range: match.closeRange)
textView.textStorage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: match.closeRange)
textView.textStorage.addAttribute(.backgroundColor, value: UIColor.systemOrange.withAlphaComponent(0.22), range: match.closeRange)
}
}
if wantsScopeBackground || wantsScopeGuides {
let textLength = fullRange.length
let scopeRange = bracketMatch?.scopeRange ?? indentationMatch?.scopeRange
let guideRanges = bracketMatch?.guideMarkerRanges ?? indentationMatch?.guideMarkerRanges ?? []
if wantsScopeBackground, let scope = scopeRange, isValidRange(scope, utf16Length: textLength) {
textView.textStorage.addAttribute(.backgroundColor, value: UIColor.systemOrange.withAlphaComponent(0.18), range: scope)
}
if wantsScopeGuides {
for marker in guideRanges {
if isValidRange(marker, utf16Length: textLength) {
textView.textStorage.addAttribute(.backgroundColor, value: UIColor.systemBlue.withAlphaComponent(0.36), range: marker)
}
}
}
}
if self.parent.highlightCurrentLine {
let ns = text as NSString
let lineRange = ns.lineRange(for: selectedRange)
@ -1892,6 +2391,7 @@ struct CustomTextEditor: UIViewRepresentable {
self.lastColorScheme = colorScheme
self.lastLineHeight = self.parent.lineHeightMultiple
self.lastHighlightToken = token
self.lastSelectionLocation = selectedRange.location
self.container?.updateLineNumbers(for: text, fontSize: self.parent.fontSize)
self.syncLineNumberScroll()
}
@ -1904,6 +2404,11 @@ struct CustomTextEditor: UIViewRepresentable {
scheduleHighlightIfNeeded(currentText: textView.text)
}
func textViewDidChangeSelection(_ textView: UITextView) {
guard !isApplyingHighlight else { return }
scheduleHighlightIfNeeded(currentText: textView.text, immediate: true)
}
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
if text == "\n", parent.autoIndentEnabled {
let ns = textView.text as NSString

View file

@ -10,13 +10,22 @@ struct NeonSettingsView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@AppStorage("SettingsOpenInTabs") private var openInTabs: String = "system"
@AppStorage("SettingsEditorFontName") private var editorFontName: String = ""
@AppStorage("SettingsUseSystemFont") private var useSystemFont: Bool = false
@AppStorage("SettingsEditorFontSize") private var editorFontSize: Double = 14
@AppStorage("SettingsLineHeight") private var lineHeight: Double = 1.0
@AppStorage("SettingsAppearance") private var appearance: String = "system"
@AppStorage("EnableTranslucentWindow") private var translucentWindow: Bool = false
@AppStorage("SettingsReopenLastSession") private var reopenLastSession: Bool = true
@AppStorage("SettingsOpenWithBlankDocument") private var openWithBlankDocument: Bool = true
@AppStorage("SettingsDefaultNewFileLanguage") private var defaultNewFileLanguage: String = "plain"
@AppStorage("SettingsConfirmCloseDirtyTab") private var confirmCloseDirtyTab: Bool = true
@AppStorage("SettingsConfirmClearEditor") private var confirmClearEditor: Bool = true
@AppStorage("SettingsShowLineNumbers") private var showLineNumbers: Bool = true
@AppStorage("SettingsHighlightCurrentLine") private var highlightCurrentLine: Bool = false
@AppStorage("SettingsHighlightMatchingBrackets") private var highlightMatchingBrackets: Bool = false
@AppStorage("SettingsShowScopeGuides") private var showScopeGuides: Bool = false
@AppStorage("SettingsHighlightScopeBackground") private var highlightScopeBackground: Bool = false
@AppStorage("SettingsLineWrapEnabled") private var lineWrapEnabled: Bool = false
@AppStorage("SettingsIndentStyle") private var indentStyle: String = "spaces"
@AppStorage("SettingsIndentWidth") private var indentWidth: Int = 4
@ -28,6 +37,7 @@ struct NeonSettingsView: View {
@AppStorage("SettingsCompletionEnabled") private var completionEnabled: Bool = false
@AppStorage("SettingsCompletionFromDocument") private var completionFromDocument: Bool = false
@AppStorage("SettingsCompletionFromSyntax") private var completionFromSyntax: Bool = false
@AppStorage("SelectedAIModel") private var selectedAIModelRaw: String = AIModel.appleIntelligence.rawValue
@AppStorage("SettingsActiveTab") private var settingsActiveTab: String = "general"
@AppStorage("SettingsTemplateLanguage") private var settingsTemplateLanguage: String = "swift"
#if os(macOS)
@ -39,6 +49,7 @@ struct NeonSettingsView: View {
@State private var geminiAPIToken: String = SecureTokenStore.token(for: .gemini)
@State private var anthropicAPIToken: String = SecureTokenStore.token(for: .anthropic)
@State private var showSupportPurchaseDialog: Bool = false
@State private var availableEditorFonts: [String] = []
private let privacyPolicyURL = URL(string: "https://github.com/h3pdesign/Neon-Vision-Editor/blob/main/PRIVACY.md")
@AppStorage("SettingsThemeName") private var selectedTheme: String = "Neon Glow"
@ -121,20 +132,43 @@ struct NeonSettingsView: View {
#if os(macOS)
.frame(minWidth: 860, minHeight: 620)
#endif
.preferredColorScheme(preferredColorSchemeOverride)
.onAppear {
if settingsActiveTab == "code" {
settingsActiveTab = "editor"
}
settingsActiveTab = "general"
refreshAvailableEditorFonts()
if supportPurchaseManager.supportProduct == nil {
Task { await supportPurchaseManager.refreshStoreState() }
}
#if os(macOS)
fontPicker.onChange = { selected in
useSystemFont = false
editorFontName = selected.fontName
editorFontSize = Double(selected.pointSize)
}
applyAppearanceImmediately()
#endif
}
.onChange(of: appearance) { _, _ in
#if os(macOS)
applyAppearanceImmediately()
#endif
}
.onChange(of: showScopeGuides) { _, enabled in
if enabled && lineWrapEnabled {
lineWrapEnabled = false
}
}
.onChange(of: highlightScopeBackground) { _, enabled in
if enabled && lineWrapEnabled {
lineWrapEnabled = false
}
}
.onChange(of: lineWrapEnabled) { _, enabled in
if enabled {
showScopeGuides = false
highlightScopeBackground = false
}
}
.confirmationDialog("Support Neon Vision Editor", isPresented: $showSupportPurchaseDialog, titleVisibility: .visible) {
Button("Support \(supportPurchaseManager.supportPriceLabel)") {
Task { await supportPurchaseManager.purchaseSupport() }
@ -159,6 +193,36 @@ struct NeonSettingsView: View {
}
}
private var preferredColorSchemeOverride: ColorScheme? {
switch appearance {
case "light":
return .light
case "dark":
return .dark
default:
return nil
}
}
#if os(macOS)
private func applyAppearanceImmediately() {
let target: NSAppearance?
switch appearance {
case "light":
target = NSAppearance(named: .aqua)
case "dark":
target = NSAppearance(named: .darkAqua)
default:
target = nil
}
NSApp.appearance = target
for window in NSApp.windows {
window.appearance = target
window.displayIfNeeded()
}
}
#endif
private var generalTab: some View {
settingsContainer {
GroupBox("Window") {
@ -197,29 +261,63 @@ struct NeonSettingsView: View {
GroupBox("Editor Font") {
VStack(alignment: .leading, spacing: 12) {
Toggle("Use System Font", isOn: $useSystemFont)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(alignment: .center, spacing: 12) {
Text("Font Name")
Text("Font")
.frame(width: isCompactSettingsLayout ? nil : 140, alignment: .leading)
TextField("Font Name", text: $editorFontName)
.textFieldStyle(.plain)
.padding(.vertical, 6)
.padding(.horizontal, 8)
.background(inputFieldBackground)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.35), lineWidth: 1)
)
.cornerRadius(6)
.frame(maxWidth: isCompactSettingsLayout ? .infinity : 240)
Picker("", selection: selectedFontBinding) {
Text("System").tag(systemFontSentinel)
ForEach(availableEditorFonts, id: \.self) { fontName in
Text(fontName).tag(fontName)
}
}
.pickerStyle(.menu)
.padding(.vertical, 6)
.padding(.horizontal, 8)
.background(inputFieldBackground)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.35), lineWidth: 1)
)
.cornerRadius(6)
.frame(maxWidth: isCompactSettingsLayout ? .infinity : 240, alignment: .leading)
.onChange(of: selectedFontValue) { _, _ in
useSystemFont = (selectedFontValue == systemFontSentinel)
if !useSystemFont && !selectedFontValue.isEmpty {
editorFontName = selectedFontValue
}
}
.onChange(of: useSystemFont) { _, isSystem in
if isSystem {
selectedFontValue = systemFontSentinel
} else if !editorFontName.isEmpty {
selectedFontValue = editorFontName
}
}
.onChange(of: editorFontName) { _, newValue in
guard !useSystemFont else { return }
if !newValue.isEmpty {
selectedFontValue = newValue
}
}
#if os(macOS)
Button("Choose…") {
useSystemFont = false
fontPicker.open(currentName: editorFontName, size: editorFontSize)
}
.disabled(useSystemFont)
#endif
}
HStack(alignment: .center, spacing: 12) {
Text("Font Size")
.frame(width: isCompactSettingsLayout ? nil : 140, alignment: .leading)
Stepper(value: $editorFontSize, in: 10...28, step: 1) {
Text("\(Int(editorFontSize)) pt")
}
.frame(maxWidth: 120)
.frame(maxWidth: isCompactSettingsLayout ? .infinity : 220, alignment: .leading)
}
HStack(alignment: .center, spacing: 12) {
@ -233,9 +331,66 @@ struct NeonSettingsView: View {
}
.padding(12)
}
GroupBox("Startup") {
VStack(alignment: .leading, spacing: 12) {
Toggle("Open with Blank Document", isOn: $openWithBlankDocument)
Toggle("Reopen Last Session", isOn: $reopenLastSession)
.disabled(openWithBlankDocument)
HStack(alignment: .center, spacing: 12) {
Text("Default New File Language")
.frame(width: isCompactSettingsLayout ? nil : 180, alignment: .leading)
Picker("", selection: $defaultNewFileLanguage) {
ForEach(templateLanguages, id: \.self) { lang in
Text(languageLabel(for: lang)).tag(lang)
}
}
.pickerStyle(.menu)
}
}
.padding(12)
}
GroupBox("Confirmations") {
VStack(alignment: .leading, spacing: 12) {
Toggle("Confirm Before Closing Dirty Tab", isOn: $confirmCloseDirtyTab)
Toggle("Confirm Before Clearing Editor", isOn: $confirmClearEditor)
}
.padding(12)
}
}
}
private let systemFontSentinel = "__system__"
@State private var selectedFontValue: String = "__system__"
private var selectedFontBinding: Binding<String> {
Binding(
get: {
if useSystemFont { return systemFontSentinel }
if editorFontName.isEmpty { return systemFontSentinel }
return editorFontName
},
set: { selectedFontValue = $0 }
)
}
private func refreshAvailableEditorFonts() {
#if os(macOS)
let names = NSFontManager.shared.availableFonts
#else
let names = UIFont.familyNames
.sorted()
.flatMap { UIFont.fontNames(forFamilyName: $0) }
#endif
var merged = Array(Set(names)).sorted()
if !editorFontName.isEmpty && !merged.contains(editorFontName) {
merged.insert(editorFontName, at: 0)
}
availableEditorFonts = merged
selectedFontValue = useSystemFont ? systemFontSentinel : (editorFontName.isEmpty ? systemFontSentinel : editorFontName)
}
private var editorTab: some View {
settingsContainer(maxWidth: 760) {
GroupBox("Editor") {
@ -245,7 +400,16 @@ struct NeonSettingsView: View {
.font(.headline)
Toggle("Show Line Numbers", isOn: $showLineNumbers)
Toggle("Highlight Current Line", isOn: $highlightCurrentLine)
Toggle("Highlight Matching Brackets", isOn: $highlightMatchingBrackets)
Toggle("Show Scope Guides (Non-Swift)", isOn: $showScopeGuides)
Toggle("Highlight Scoped Region", isOn: $highlightScopeBackground)
Toggle("Line Wrap", isOn: $lineWrapEnabled)
Text("When Line Wrap is enabled, scope guides/scoped region are turned off to avoid layout conflicts.")
.font(.footnote)
.foregroundStyle(.secondary)
Text("Scope guides are intended for non-Swift languages. Swift favors matching-token highlight.")
.font(.footnote)
.foregroundStyle(.secondary)
Text("Invisible character markers are disabled to avoid whitespace glyph artifacts.")
.font(.footnote)
.foregroundStyle(.secondary)
@ -318,7 +482,7 @@ struct NeonSettingsView: View {
}
TextEditor(text: templateBinding(for: settingsTemplateLanguage))
.font(.system(.body, design: .monospaced))
.font(.system(size: 13, weight: .regular, design: .monospaced))
.frame(minHeight: 200, maxHeight: 320)
.scrollContentBackground(.hidden)
.background(Color.clear)
@ -434,6 +598,26 @@ struct NeonSettingsView: View {
private var aiTab: some View {
settingsContainer(maxWidth: 520) {
GroupBox("AI Model") {
VStack(alignment: .leading, spacing: 12) {
Picker("Model", selection: selectedAIModelBinding) {
Text("Apple Intelligence").tag(AIModel.appleIntelligence)
Text("Grok").tag(AIModel.grok)
Text("OpenAI").tag(AIModel.openAI)
Text("Gemini").tag(AIModel.gemini)
Text("Anthropic").tag(AIModel.anthropic)
}
.pickerStyle(.menu)
Text("Choose the default model used by editor AI actions.")
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(12)
}
.frame(maxWidth: 420)
.frame(maxWidth: .infinity, alignment: .center)
GroupBox("AI Provider API Keys") {
VStack(alignment: .center, spacing: 12) {
aiKeyRow(title: "Grok", placeholder: "sk-…", value: $grokAPIToken, provider: .grok)
@ -449,6 +633,13 @@ struct NeonSettingsView: View {
}
}
private var selectedAIModelBinding: Binding<AIModel> {
Binding(
get: { AIModel(rawValue: selectedAIModelRaw) ?? .appleIntelligence },
set: { selectedAIModelRaw = $0.rawValue }
)
}
private var supportTab: some View {
settingsContainer(maxWidth: 520) {
GroupBox("Support Development") {

View file

@ -558,6 +558,7 @@ extension Notification.Name {
static let selectAIModelRequested = Notification.Name("selectAIModelRequested")
static let showQuickSwitcherRequested = Notification.Name("showQuickSwitcherRequested")
static let showWelcomeTourRequested = Notification.Name("showWelcomeTourRequested")
static let moveCursorToRange = Notification.Name("moveCursorToRange")
static let toggleVimModeRequested = Notification.Name("toggleVimModeRequested")
static let vimModeStateDidChange = Notification.Name("vimModeStateDidChange")
static let droppedFileURL = Notification.Name("droppedFileURL")
@ -578,6 +579,8 @@ extension NSRange {
enum EditorCommandUserInfo {
static let windowNumber = "targetWindowNumber"
static let inspectionMessage = "inspectionMessage"
static let rangeLocation = "rangeLocation"
static let rangeLength = "rangeLength"
}
#if os(macOS)

View file

@ -213,6 +213,14 @@ cd Neon-Vision-Editor
open "Neon Vision Editor.xcodeproj"
```
## Git hooks
To auto-increment Xcode `CURRENT_PROJECT_VERSION` on every commit:
```bash
scripts/install_git_hooks.sh
```
## Support
If you want to support development:

21
scripts/bump_build_number.sh Executable file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_FILE="${1:-Neon Vision Editor.xcodeproj/project.pbxproj}"
if [[ ! -f "$PROJECT_FILE" ]]; then
echo "Project file not found: $PROJECT_FILE" >&2
exit 2
fi
current="$(awk '/CURRENT_PROJECT_VERSION = [0-9]+;/{gsub(/[^0-9]/, "", $0); print; exit}' "$PROJECT_FILE")"
if [[ -z "${current:-}" ]]; then
echo "Could not find CURRENT_PROJECT_VERSION in $PROJECT_FILE" >&2
exit 2
fi
next=$((current + 1))
perl -0pi -e "s/CURRENT_PROJECT_VERSION = $current;/CURRENT_PROJECT_VERSION = $next;/g" "$PROJECT_FILE"
echo "Bumped CURRENT_PROJECT_VERSION: $current -> $next"

10
scripts/install_git_hooks.sh Executable file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"
chmod +x .githooks/pre-commit scripts/bump_build_number.sh
git config core.hooksPath .githooks
echo "Git hooks installed. pre-commit will auto-bump CURRENT_PROJECT_VERSION."

View file

@ -6,19 +6,20 @@ usage() {
Run end-to-end release flow in one command.
Usage:
scripts/release_all.sh <tag> [--date YYYY-MM-DD] [--notarized]
scripts/release_all.sh <tag> [--date YYYY-MM-DD] [--notarized] [--self-hosted]
Examples:
scripts/release_all.sh v0.4.6
scripts/release_all.sh 0.4.6 --date 2026-02-12
scripts/release_all.sh v0.4.6 --notarized
scripts/release_all.sh v0.4.6 --notarized --self-hosted
What it does:
1) Prepare README/CHANGELOG docs
2) Commit docs changes
3) Create annotated tag
4) Push main and tag to origin
5) (optional) Trigger self-hosted notarized release workflow
5) (optional) Trigger notarized release workflow (GitHub-hosted by default)
EOF
}
@ -37,6 +38,7 @@ fi
DATE_ARG=()
TRIGGER_NOTARIZED=0
USE_SELF_HOSTED=0
while [[ "${1:-}" != "" ]]; do
case "$1" in
@ -51,6 +53,9 @@ while [[ "${1:-}" != "" ]]; do
--notarized)
TRIGGER_NOTARIZED=1
;;
--self-hosted)
USE_SELF_HOSTED=1
;;
*)
echo "Unknown argument: $1" >&2
usage
@ -77,12 +82,18 @@ echo "Tag push completed. Unsigned release workflow should start automatically."
if [[ "$TRIGGER_NOTARIZED" -eq 1 ]]; then
echo "Triggering notarized workflow for ${TAG}..."
gh workflow run release-notarized-selfhosted.yml -f tag="$TAG"
echo "Triggered: release-notarized-selfhosted.yml (tag=${TAG})"
if [[ "$USE_SELF_HOSTED" -eq 1 ]]; then
gh workflow run release-notarized-selfhosted.yml -f tag="$TAG" -f use_self_hosted=true
echo "Triggered: release-notarized-selfhosted.yml (tag=${TAG}, use_self_hosted=true)"
else
gh workflow run release-notarized.yml -f tag="$TAG"
echo "Triggered: release-notarized.yml (tag=${TAG})"
fi
fi
echo
echo "Done."
echo "Check runs:"
echo " gh run list --workflow release.yml --limit 5"
echo " gh run list --workflow release-notarized.yml --limit 5"
echo " gh run list --workflow release-notarized-selfhosted.yml --limit 5"