mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 13:27:16 +00:00
Prepare v0.4.7: scope highlighting and settings persistence fixes
This commit is contained in:
parent
2f5f7d1835
commit
5f8041480e
18 changed files with 1131 additions and 219 deletions
10
.githooks/pre-commit
Executable file
10
.githooks/pre-commit
Executable 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
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ final class SupportPurchaseManager: ObservableObject {
|
|||
}
|
||||
|
||||
var supportPriceLabel: String {
|
||||
supportProduct?.displayPrice ?? "EUR 4.90"
|
||||
supportProduct?.displayPrice ?? "$4.99"
|
||||
}
|
||||
|
||||
var canBypassInCurrentBuild: Bool {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
],
|
||||
"products" : [
|
||||
{
|
||||
"displayPrice" : "4.90",
|
||||
"displayPrice" : "4.99",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "0D5E32E6-73E8-4DA0-9AE8-4C5A79EA9A20",
|
||||
"localizations" : [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
21
scripts/bump_build_number.sh
Executable 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
10
scripts/install_git_hooks.sh
Executable 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."
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue