Improve editor UX across iOS, iPadOS, and macOS

iOS
- Keep compact, phone-appropriate toolbar behavior
- Improve toolbar/menu responsiveness and action access consistency
- Include mobile editor parity fixes (syntax highlighting and line-number visibility)

iPadOS
- Make toolbar width adaptive to device/screen size
- Keep toolbar height compact (matching iPhone-style vertical density)
- Distribute toolbar controls across available width
- Promote key overflow actions to visible toolbar buttons when space allows
  (open/save, sidebar toggles, find/replace, wrap, completion), with overflow fallback
- Use active UIWindowScene screen bounds for width calculation (deprecation-safe)

macOS
- Keep full toolbar + menubar action coverage aligned
- Preserve desktop toolbar behavior while iOS/iPadOS layouts diverge
- Retain platform-specific toolbar/menu polish without regressions
This commit is contained in:
h3p 2026-02-07 11:51:52 +01:00
parent 1736888047
commit 0945b23b01
14 changed files with 1012 additions and 50 deletions

View file

@ -264,7 +264,7 @@
AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 90;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES;
@ -301,7 +301,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 0.3.2;
MARKETING_VERSION = 0.3.3;
PRODUCT_BUNDLE_IDENTIFIER = "h3p.Neon-Vision-Editor";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -335,7 +335,7 @@
AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 90;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES;
@ -372,7 +372,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 0.3.2;
MARKETING_VERSION = 0.3.3;
PRODUCT_BUNDLE_IDENTIFIER = "h3p.Neon-Vision-Editor";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View file

@ -31,7 +31,7 @@
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Release"
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"

View file

@ -1,8 +1,103 @@
import SwiftUI
import AppKit
import Foundation
#if os(macOS)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif
extension ContentView {
func openFileFromToolbar() {
#if os(macOS)
viewModel.openFile()
#else
showIOSFileImporter = true
#endif
}
func saveCurrentTabFromToolbar() {
guard let tab = viewModel.selectedTab else { return }
#if os(macOS)
viewModel.saveFile(tab: tab)
#else
if tab.fileURL != nil {
viewModel.saveFile(tab: tab)
if let updated = viewModel.tabs.first(where: { $0.id == tab.id }), !updated.isDirty {
return
}
}
iosExportTabID = tab.id
iosExportDocument = PlainTextDocument(text: tab.content)
iosExportFilename = suggestedExportFilename(for: tab)
showIOSFileExporter = true
#endif
}
#if canImport(UIKit)
func handleIOSImportResult(_ result: Result<[URL], Error>) {
switch result {
case .success(let urls):
guard let url = urls.first else { return }
let didStart = url.startAccessingSecurityScopedResource()
defer {
if didStart {
url.stopAccessingSecurityScopedResource()
}
}
viewModel.openFile(url: url)
findStatusMessage = ""
case .failure(let error):
findStatusMessage = "Open failed: \(error.localizedDescription)"
}
}
func handleIOSExportResult(_ result: Result<URL, Error>) {
switch result {
case .success(let url):
if let tabID = iosExportTabID {
viewModel.markTabSaved(tabID: tabID, fileURL: url)
}
findStatusMessage = ""
case .failure(let error):
findStatusMessage = "Save failed: \(error.localizedDescription)"
}
iosExportTabID = nil
}
private func suggestedExportFilename(for tab: TabData) -> String {
if tab.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "Untitled.txt"
}
if tab.name.contains(".") {
return tab.name
}
return "\(tab.name).txt"
}
#endif
func clearEditorContent() {
currentContentBinding.wrappedValue = ""
#if os(macOS)
if let tv = NSApp.keyWindow?.firstResponder as? NSTextView {
tv.string = ""
tv.didChangeText()
tv.setSelectedRange(NSRange(location: 0, length: 0))
tv.scrollRangeToVisible(NSRange(location: 0, length: 0))
}
#endif
caretStatus = "Ln 1, Col 1"
}
func toggleSidebarFromToolbar() {
#if os(iOS)
if horizontalSizeClass == .compact {
showCompactSidebarSheet.toggle()
return
}
#endif
viewModel.showSidebar.toggle()
}
func requestCloseTab(_ tab: TabData) {
if tab.isDirty {
pendingCloseTabID = tab.id
@ -41,6 +136,7 @@ extension ContentView {
}
func findNext() {
#if os(macOS)
guard !findQuery.isEmpty, let tv = activeEditorTextView() else { return }
findStatusMessage = ""
let ns = tv.string as NSString
@ -73,9 +169,13 @@ extension ContentView {
NSSound.beep()
}
}
#else
findStatusMessage = "Find next is currently available on macOS editor."
#endif
}
func replaceSelection() {
#if os(macOS)
guard let tv = activeEditorTextView() else { return }
let sel = tv.selectedRange()
guard sel.length > 0 else { return }
@ -92,9 +192,19 @@ extension ContentView {
} else {
tv.insertText(replaceQuery, replacementRange: sel)
}
#else
// iOS fallback: replace all exact text when regex is off.
guard !findQuery.isEmpty else { return }
if findUsesRegex {
findStatusMessage = "Regex replace selection is currently available on macOS editor."
return
}
currentContentBinding.wrappedValue = currentContentBinding.wrappedValue.replacingOccurrences(of: findQuery, with: replaceQuery)
#endif
}
func replaceAll() {
#if os(macOS)
guard let tv = activeEditorTextView(), !findQuery.isEmpty else { return }
findStatusMessage = ""
let original = tv.string
@ -137,8 +247,37 @@ extension ContentView {
tv.didChangeText()
findStatusMessage = "Replaced \(count) matches"
}
#else
guard !findQuery.isEmpty else { return }
let original = currentContentBinding.wrappedValue
if findUsesRegex {
guard let regex = try? NSRegularExpression(pattern: findQuery, options: findCaseSensitive ? [] : [.caseInsensitive]) else {
findStatusMessage = "Invalid regex pattern"
return
}
let fullRange = NSRange(location: 0, length: (original as NSString).length)
let count = regex.numberOfMatches(in: original, options: [], range: fullRange)
guard count > 0 else {
findStatusMessage = "No matches found"
return
}
currentContentBinding.wrappedValue = regex.stringByReplacingMatches(in: original, options: [], range: fullRange, withTemplate: replaceQuery)
findStatusMessage = "Replaced \(count) matches"
} else {
let updated = findCaseSensitive
? original.replacingOccurrences(of: findQuery, with: replaceQuery)
: (original as NSString).replacingOccurrences(of: findQuery, with: replaceQuery, options: [.caseInsensitive], range: NSRange(location: 0, length: (original as NSString).length))
if updated == original {
findStatusMessage = "No matches found"
} else {
currentContentBinding.wrappedValue = updated
findStatusMessage = "Replace complete"
}
}
#endif
}
#if os(macOS)
private func activeEditorTextView() -> NSTextView? {
let windows = ([NSApp.keyWindow, NSApp.mainWindow].compactMap { $0 }) + NSApp.windows
for window in windows {
@ -167,8 +306,10 @@ extension ContentView {
}
return nil
}
#endif
func applyWindowTranslucency(_ enabled: Bool) {
#if os(macOS)
for window in NSApp.windows {
window.isOpaque = !enabled
window.backgroundColor = enabled ? .clear : NSColor.windowBackgroundColor
@ -177,9 +318,11 @@ extension ContentView {
window.titlebarSeparatorStyle = enabled ? .none : .automatic
}
}
#endif
}
func openProjectFolder() {
#if os(macOS)
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
@ -190,6 +333,9 @@ extension ContentView {
projectRootFolderURL = folderURL
projectTreeNodes = buildProjectTree(at: folderURL)
}
#else
findStatusMessage = "Open Folder is currently available on macOS."
#endif
}
func refreshProjectTree() {

View file

@ -1,9 +1,279 @@
import SwiftUI
#if os(macOS)
import AppKit
#elseif os(iOS)
import UIKit
#endif
extension ContentView {
#if os(iOS)
private var isIPadToolbarLayout: Bool {
UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .regular
}
private var iPadToolbarMaxWidth: CGFloat {
let screenWidth = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first(where: { $0.activationState == .foregroundActive })?
.screen.bounds.width ?? 1024
let target = screenWidth * 0.72
return min(max(target, 560), 980)
}
private var iPadPromotedActionsCount: Int {
switch iPadToolbarMaxWidth {
case 920...: return 7
case 840...: return 6
case 760...: return 5
case 680...: return 4
case 620...: return 3
default: return 2
}
}
@ViewBuilder
private var languagePickerControl: some View {
Picker("Language", selection: currentLanguageBinding) {
ForEach(["swift", "python", "javascript", "typescript", "java", "kotlin", "go", "ruby", "rust", "sql", "html", "css", "cpp", "csharp", "objective-c", "json", "xml", "yaml", "toml", "ini", "markdown", "bash", "zsh", "powershell", "standard", "plain"], id: \.self) { lang in
let label: String = {
switch lang {
case "objective-c": return "Objective-C"
case "csharp": return "C#"
case "cpp": return "C++"
case "json": return "JSON"
case "xml": return "XML"
case "yaml": return "YAML"
case "toml": return "TOML"
case "ini": return "INI"
case "sql": return "SQL"
case "html": return "HTML"
case "css": return "CSS"
case "standard": return "Standard"
default: return lang.capitalized
}
}()
Text(label).tag(lang)
}
}
.labelsHidden()
.help("Language")
.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
showAPISettings = true
}
.buttonStyle(.bordered)
}
.padding(12)
}
}
@ViewBuilder
private var activeProviderBadgeControl: some View {
Text(activeProviderName)
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.secondary.opacity(0.12), in: Capsule())
.help("Active provider")
}
@ViewBuilder
private var clearEditorControl: some View {
Button(action: {
clearEditorContent()
}) {
Image(systemName: "trash")
}
.help("Clear Editor")
}
@ViewBuilder
private var openFileControl: some View {
Button(action: { openFileFromToolbar() }) {
Image(systemName: "folder")
}
.help("Open File…")
}
@ViewBuilder
private var saveFileControl: some View {
Button(action: { saveCurrentTabFromToolbar() }) {
Image(systemName: "square.and.arrow.down")
}
.disabled(viewModel.selectedTab == nil)
.help("Save File")
}
@ViewBuilder
private var toggleSidebarControl: some View {
Button(action: { toggleSidebarFromToolbar() }) {
Image(systemName: "sidebar.left")
}
.help("Toggle Sidebar")
}
@ViewBuilder
private var toggleProjectSidebarControl: some View {
Button(action: { showProjectStructureSidebar.toggle() }) {
Image(systemName: "sidebar.right")
}
.help("Toggle Project Structure Sidebar")
}
@ViewBuilder
private var findReplaceControl: some View {
Button(action: { showFindReplace = true }) {
Image(systemName: "magnifyingglass")
}
.help("Find & Replace")
}
@ViewBuilder
private var lineWrapControl: some View {
Button(action: { viewModel.isLineWrapEnabled.toggle() }) {
Image(systemName: viewModel.isLineWrapEnabled ? "text.justify" : "text.alignleft")
}
.help(viewModel.isLineWrapEnabled ? "Disable Wrap" : "Enable Wrap")
}
@ViewBuilder
private var autoCompletionControl: some View {
Button(action: { isAutoCompletionEnabled.toggle() }) {
Image(systemName: isAutoCompletionEnabled ? "bolt.horizontal.circle.fill" : "bolt.horizontal.circle")
}
.help(isAutoCompletionEnabled ? "Disable Code Completion" : "Enable Code Completion")
}
@ViewBuilder
private var iPadPromotedActions: some View {
if iPadPromotedActionsCount >= 1 { openFileControl }
if iPadPromotedActionsCount >= 2 { saveFileControl }
if iPadPromotedActionsCount >= 3 { toggleSidebarControl }
if iPadPromotedActionsCount >= 4 { toggleProjectSidebarControl }
if iPadPromotedActionsCount >= 5 { findReplaceControl }
if iPadPromotedActionsCount >= 6 { lineWrapControl }
if iPadPromotedActionsCount >= 7 { autoCompletionControl }
}
@ViewBuilder
private var moreActionsControl: some View {
Menu {
Button(action: { isAutoCompletionEnabled.toggle() }) {
Label(isAutoCompletionEnabled ? "Disable Code Completion" : "Enable Code Completion", systemImage: isAutoCompletionEnabled ? "bolt.horizontal.circle.fill" : "bolt.horizontal.circle")
}
Button(action: { openFileFromToolbar() }) {
Label("Open File…", systemImage: "folder")
}
Button(action: { saveCurrentTabFromToolbar() }) {
Label("Save File", systemImage: "square.and.arrow.down")
}
.disabled(viewModel.selectedTab == nil)
Button(action: { toggleSidebarFromToolbar() }) {
Label("Toggle Sidebar", systemImage: "sidebar.left")
}
Button(action: { showProjectStructureSidebar.toggle() }) {
Label("Toggle Project Structure Sidebar", systemImage: "sidebar.right")
}
Button(action: { showFindReplace = true }) {
Label("Find & Replace", systemImage: "magnifyingglass")
}
Button(action: {
viewModel.isBrainDumpMode.toggle()
UserDefaults.standard.set(viewModel.isBrainDumpMode, forKey: "BrainDumpModeEnabled")
}) {
Label("Brain Dump Mode", systemImage: "note.text")
}
Button(action: {
enableTranslucentWindow.toggle()
UserDefaults.standard.set(enableTranslucentWindow, forKey: "EnableTranslucentWindow")
NotificationCenter.default.post(name: .toggleTranslucencyRequested, object: enableTranslucentWindow)
}) {
Label("Translucent Window Background", systemImage: enableTranslucentWindow ? "rectangle.fill" : "rectangle")
}
Button(action: { viewModel.isLineWrapEnabled.toggle() }) {
Label(viewModel.isLineWrapEnabled ? "Disable Wrap" : "Enable Wrap", systemImage: viewModel.isLineWrapEnabled ? "text.justify" : "text.alignleft")
}
} label: {
Image(systemName: "ellipsis.circle")
}
.help("More Actions")
}
@ViewBuilder
private var iOSToolbarControls: some View {
languagePickerControl
aiSelectorControl
activeProviderBadgeControl
clearEditorControl
moreActionsControl
}
@ViewBuilder
private var iPadDistributedToolbarControls: some View {
languagePickerControl
Spacer(minLength: 18)
iPadPromotedActions
Spacer(minLength: 18)
aiSelectorControl
activeProviderBadgeControl
Spacer(minLength: 18)
clearEditorControl
moreActionsControl
}
#endif
@ToolbarContentBuilder
var editorToolbarContent: some ToolbarContent {
#if os(iOS)
ToolbarItemGroup(placement: .topBarTrailing) {
HStack(spacing: 14) {
if isIPadToolbarLayout {
iPadDistributedToolbarControls
} else {
iOSToolbarControls
}
}
.frame(maxWidth: isIPadToolbarLayout ? iPadToolbarMaxWidth : .infinity, alignment: .trailing)
}
#else
ToolbarItemGroup(placement: .automatic) {
Picker("Language", selection: currentLanguageBinding) {
ForEach(["swift", "python", "javascript", "typescript", "java", "kotlin", "go", "ruby", "rust", "sql", "html", "css", "cpp", "csharp", "objective-c", "json", "xml", "yaml", "toml", "ini", "markdown", "bash", "zsh", "powershell", "standard", "plain"], id: \.self) { lang in
@ -75,14 +345,7 @@ extension ContentView {
.help("Active provider")
Button(action: {
currentContentBinding.wrappedValue = ""
if let tv = NSApp.keyWindow?.firstResponder as? NSTextView {
tv.string = ""
tv.didChangeText()
tv.setSelectedRange(NSRange(location: 0, length: 0))
tv.scrollRangeToVisible(NSRange(location: 0, length: 0))
}
caretStatus = "Ln 1, Col 1"
clearEditorContent()
}) {
Image(systemName: "trash")
}
@ -95,20 +358,22 @@ extension ContentView {
}
.help(isAutoCompletionEnabled ? "Disable Code Completion" : "Enable Code Completion")
Button(action: { viewModel.openFile() }) {
Button(action: { openFileFromToolbar() }) {
Image(systemName: "folder")
}
.help("Open File…")
#if os(macOS)
Button(action: {
openWindow(id: "blank-window")
}) {
Image(systemName: "macwindow.badge.plus")
}
.help("New Window")
#endif
Button(action: {
if let tab = viewModel.selectedTab { viewModel.saveFile(tab: tab) }
saveCurrentTabFromToolbar()
}) {
Image(systemName: "square.and.arrow.down")
}
@ -116,7 +381,7 @@ extension ContentView {
.help("Save File")
Button(action: {
viewModel.showSidebar.toggle()
toggleSidebarFromToolbar()
}) {
Image(systemName: "sidebar.left")
.symbolVariant(viewModel.showSidebar ? .fill : .none)
@ -164,5 +429,6 @@ extension ContentView {
}
.help(viewModel.isLineWrapEnabled ? "Disable Wrap" : "Enable Wrap")
}
#endif
}
}

View file

@ -4,8 +4,13 @@
// MARK: - Imports
import SwiftUI
import AppKit
import Foundation
import UniformTypeIdentifiers
#if os(macOS)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif
#if USE_FOUNDATION_MODELS
import FoundationModels
#endif
@ -13,11 +18,13 @@ import FoundationModels
// Utility: quick width calculation for strings with a given font (AppKit-based)
extension String {
#if os(macOS)
func width(usingFont font: NSFont) -> CGFloat {
let attributes = [NSAttributedString.Key.font: font]
let size = (self as NSString).size(withAttributes: attributes)
return size.width
}
#endif
}
// MARK: - Root view for the editor.
@ -26,7 +33,12 @@ struct ContentView: View {
// Environment-provided view model and theme/error bindings
@EnvironmentObject var viewModel: EditorViewModel
@Environment(\.colorScheme) var colorScheme
#if os(iOS)
@Environment(\.horizontalSizeClass) var horizontalSizeClass
#endif
#if os(macOS)
@Environment(\.openWindow) var openWindow
#endif
@Environment(\.showGrokError) var showGrokError
@Environment(\.grokErrorMessage) var grokErrorMessage
@ -60,10 +72,16 @@ struct ContentView: View {
@State var findCaseSensitive: Bool = false
@State var findStatusMessage: String = ""
@State var showProjectStructureSidebar: Bool = false
@State var showCompactSidebarSheet: Bool = false
@State var projectRootFolderURL: URL? = nil
@State var projectTreeNodes: [ProjectTreeNode] = []
@State var pendingCloseTabID: UUID? = nil
@State var showUnsavedCloseDialog: Bool = false
@State var showIOSFileImporter: Bool = false
@State var showIOSFileExporter: Bool = false
@State var iosExportDocument: PlainTextDocument = PlainTextDocument(text: "")
@State var iosExportFilename: String = "Untitled.txt"
@State var iosExportTabID: UUID? = nil
#if USE_FOUNDATION_MODELS
var appleModelAvailable: Bool { true }
@ -77,6 +95,7 @@ struct ContentView: View {
/// Returns true if a token is present/was saved; false if cancelled or empty.
private func promptForGrokTokenIfNeeded() -> Bool {
if !grokAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true }
#if os(macOS)
let alert = NSAlert()
alert.messageText = "Grok API Token Required"
alert.informativeText = "Enter your Grok API token to enable suggestions. You can obtain this from your Grok account."
@ -94,6 +113,7 @@ struct ContentView: View {
UserDefaults.standard.set(token, forKey: "GrokAPIToken")
return true
}
#endif
return false
}
@ -101,6 +121,7 @@ struct ContentView: View {
/// Returns true if a token is present/was saved; false if cancelled or empty.
private func promptForOpenAITokenIfNeeded() -> Bool {
if !openAIAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true }
#if os(macOS)
let alert = NSAlert()
alert.messageText = "OpenAI API Token Required"
alert.informativeText = "Enter your OpenAI API token to enable suggestions."
@ -118,6 +139,7 @@ struct ContentView: View {
UserDefaults.standard.set(token, forKey: "OpenAIAPIToken")
return true
}
#endif
return false
}
@ -125,6 +147,7 @@ struct ContentView: View {
/// Returns true if a token is present/was saved; false if cancelled or empty.
private func promptForGeminiTokenIfNeeded() -> Bool {
if !geminiAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true }
#if os(macOS)
let alert = NSAlert()
alert.messageText = "Gemini API Key Required"
alert.informativeText = "Enter your Gemini API key to enable suggestions."
@ -142,6 +165,7 @@ struct ContentView: View {
UserDefaults.standard.set(token, forKey: "GeminiAPIToken")
return true
}
#endif
return false
}
@ -149,6 +173,7 @@ struct ContentView: View {
/// Returns true if a token is present/was saved; false if cancelled or empty.
private func promptForAnthropicTokenIfNeeded() -> Bool {
if !anthropicAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true }
#if os(macOS)
let alert = NSAlert()
alert.messageText = "Anthropic API Token Required"
alert.informativeText = "Enter your Anthropic API token to enable suggestions."
@ -166,6 +191,7 @@ struct ContentView: View {
UserDefaults.standard.set(token, forKey: "AnthropicAPIToken")
return true
}
#endif
return false
}
@ -176,6 +202,7 @@ struct ContentView: View {
}
private func performInlineCompletionAsync() async {
#if os(macOS)
guard let textView = NSApp.keyWindow?.firstResponder as? NSTextView else { return }
let sel = textView.selectedRange()
guard sel.length == 0 else { return }
@ -255,6 +282,10 @@ struct ContentView: View {
// Scroll to visible range of inserted text
textView.scrollRangeToVisible(NSRange(location: sel.location + (suggestion as NSString).length, length: 0))
}
#else
// iOS inline completion hook can be added for UITextView selection APIs.
return
#endif
}
private func externalModelCompletion(prefix: String, language: String) async -> String {
@ -638,10 +669,11 @@ struct ContentView: View {
return result
}
// Layout: NavigationSplitView with optional sidebar and the primary code editor.
var body: some View {
@ViewBuilder
private var platformLayout: some View {
#if os(macOS)
Group {
if viewModel.showSidebar && !viewModel.isBrainDumpMode {
if shouldUseSplitView {
NavigationSplitView {
sidebarView
} detail: {
@ -650,11 +682,33 @@ struct ContentView: View {
.navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 600)
.background(enableTranslucentWindow ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(Color.clear))
} else {
// Fully collapsed: render only the editor without a split view
editorView
}
}
.frame(minWidth: 600, minHeight: 400)
#else
NavigationStack {
Group {
if shouldUseSplitView {
NavigationSplitView {
sidebarView
} detail: {
editorView
}
.navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 600)
.background(enableTranslucentWindow ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(Color.clear))
} else {
editorView
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
#endif
}
// Layout: NavigationSplitView with optional sidebar and the primary code editor.
var body: some View {
platformLayout
.alert("AI Error", isPresented: showGrokError) {
Button("OK") { }
} message: {
@ -680,8 +734,28 @@ struct ContentView: View {
onReplace: { replaceSelection() },
onReplaceAll: { replaceAll() }
)
#if canImport(UIKit)
.frame(maxWidth: 420)
#else
.frame(width: 420)
#endif
}
#if os(iOS)
.sheet(isPresented: $showCompactSidebarSheet) {
NavigationStack {
SidebarView(content: currentContent, language: currentLanguage)
.navigationTitle("Sidebar")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
showCompactSidebarSheet = false
}
}
}
}
.presentationDetents([.medium, .large])
}
#endif
.confirmationDialog("Save changes before closing?", isPresented: $showUnsavedCloseDialog, titleVisibility: .visible) {
Button("Save") { saveAndClosePendingTab() }
Button("Don't Save", role: .destructive) { discardAndClosePendingTab() }
@ -696,6 +770,23 @@ struct ContentView: View {
Text("This file has unsaved changes.")
}
}
#if canImport(UIKit)
.fileImporter(
isPresented: $showIOSFileImporter,
allowedContentTypes: [.text, .plainText, .sourceCode, .json, .xml, .yaml],
allowsMultipleSelection: false
) { result in
handleIOSImportResult(result)
}
.fileExporter(
isPresented: $showIOSFileExporter,
document: iosExportDocument,
contentType: .plainText,
defaultFilename: iosExportFilename
) { result in
handleIOSExportResult(result)
}
#endif
.onAppear {
// Start with sidebar collapsed by default
viewModel.showSidebar = false
@ -710,6 +801,15 @@ struct ContentView: View {
}
}
private var shouldUseSplitView: Bool {
#if os(macOS)
return viewModel.showSidebar && !viewModel.isBrainDumpMode
#else
// Keep iPhone layout single-column to avoid horizontal clipping.
return viewModel.showSidebar && !viewModel.isBrainDumpMode && horizontalSizeClass == .regular
#endif
}
// Sidebar shows a lightweight table of contents (TOC) derived from the current document.
@ViewBuilder
var sidebarView: some View {
@ -917,7 +1017,7 @@ struct ContentView: View {
nodes: projectTreeNodes,
selectedFileURL: viewModel.selectedTab?.fileURL,
translucentBackgroundEnabled: enableTranslucentWindow,
onOpenFile: { viewModel.openFile() },
onOpenFile: { openFileFromToolbar() },
onOpenFolder: { openProjectFolder() },
onOpenProjectFile: { openProjectFile(url: $0) },
onRefreshTree: { refreshProjectTree() }
@ -925,6 +1025,7 @@ struct ContentView: View {
.frame(minWidth: 220, idealWidth: 260, maxWidth: 340)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onReceive(NotificationCenter.default.publisher(for: .caretPositionDidChange)) { notif in
// Update status line when caret moves
if let line = notif.userInfo?["line"] as? Int, let col = notif.userInfo?["column"] as? Int {
@ -937,6 +1038,28 @@ struct ContentView: View {
currentLanguageBinding.wrappedValue = result.lang == "plain" ? "swift" : result.lang
}
}
.onReceive(NotificationCenter.default.publisher(for: .clearEditorRequested)) { _ in
clearEditorContent()
}
.onReceive(NotificationCenter.default.publisher(for: .toggleCodeCompletionRequested)) { _ in
isAutoCompletionEnabled.toggle()
}
.onReceive(NotificationCenter.default.publisher(for: .showFindReplaceRequested)) { _ in
showFindReplace = true
}
.onReceive(NotificationCenter.default.publisher(for: .toggleProjectStructureSidebarRequested)) { _ in
showProjectStructureSidebar.toggle()
}
.onReceive(NotificationCenter.default.publisher(for: .showAPISettingsRequested)) { _ in
showAISelectorPopover = false
showAPISettings = true
}
.onReceive(NotificationCenter.default.publisher(for: .selectAIModelRequested)) { notif in
guard let modelRawValue = notif.object as? String,
let model = AIModel(rawValue: modelRawValue) else { return }
selectedModel = model
}
#if os(macOS)
.onReceive(NotificationCenter.default.publisher(for: NSText.didChangeNotification)) { _ in
guard isAutoCompletionEnabled && !viewModel.isBrainDumpMode else { return }
lastCompletionWorkItem?.cancel()
@ -946,13 +1069,18 @@ struct ContentView: View {
lastCompletionWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: work)
}
#endif
.onChange(of: enableTranslucentWindow) { _, newValue in
applyWindowTranslucency(newValue)
}
.toolbar {
editorToolbarContent
}
#if os(macOS)
.toolbarBackground(enableTranslucentWindow ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(Color(nsColor: .windowBackgroundColor)), for: .windowToolbar)
#else
.toolbarBackground(enableTranslucentWindow ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(Color(.systemBackground)), for: .navigationBar)
#endif
}
// Status line: caret location + live word count from the view model.
@ -1004,7 +1132,11 @@ struct ContentView: View {
.padding(.horizontal, 10)
.padding(.vertical, 6)
}
#if os(macOS)
.background(enableTranslucentWindow ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(Color(nsColor: .windowBackgroundColor)))
#else
.background(enableTranslucentWindow ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(Color(.systemBackground)))
#endif
}
}

View file

@ -1,7 +1,9 @@
import SwiftUI
import AppKit
import Foundation
#if os(macOS)
import AppKit
final class AcceptingTextView: NSTextView {
override var acceptsFirstResponder: Bool { true }
override var mouseDownCanMoveWindow: Bool { false }
@ -561,3 +563,215 @@ struct CustomTextEditor: NSViewRepresentable {
}
}
}
#else
import UIKit
final class LineNumberedTextViewContainer: UIView {
let lineNumberView = UITextView()
let textView = UITextView()
override init(frame: CGRect) {
super.init(frame: frame)
lineNumberView.translatesAutoresizingMaskIntoConstraints = false
textView.translatesAutoresizingMaskIntoConstraints = false
lineNumberView.isEditable = false
lineNumberView.isSelectable = false
lineNumberView.isScrollEnabled = true
lineNumberView.isUserInteractionEnabled = false
lineNumberView.backgroundColor = UIColor.secondarySystemBackground.withAlphaComponent(0.65)
lineNumberView.textColor = .secondaryLabel
lineNumberView.textAlignment = .right
lineNumberView.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 6)
lineNumberView.textContainer.lineFragmentPadding = 0
textView.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
let divider = UIView()
divider.translatesAutoresizingMaskIntoConstraints = false
divider.backgroundColor = UIColor.separator.withAlphaComponent(0.6)
addSubview(lineNumberView)
addSubview(divider)
addSubview(textView)
NSLayoutConstraint.activate([
lineNumberView.leadingAnchor.constraint(equalTo: leadingAnchor),
lineNumberView.topAnchor.constraint(equalTo: topAnchor),
lineNumberView.bottomAnchor.constraint(equalTo: bottomAnchor),
lineNumberView.widthAnchor.constraint(equalToConstant: 46),
divider.leadingAnchor.constraint(equalTo: lineNumberView.trailingAnchor),
divider.topAnchor.constraint(equalTo: topAnchor),
divider.bottomAnchor.constraint(equalTo: bottomAnchor),
divider.widthAnchor.constraint(equalToConstant: 1),
textView.leadingAnchor.constraint(equalTo: divider.trailingAnchor),
textView.trailingAnchor.constraint(equalTo: trailingAnchor),
textView.topAnchor.constraint(equalTo: topAnchor),
textView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func updateLineNumbers(for text: String, fontSize: CGFloat) {
let lineCount = max(1, text.components(separatedBy: .newlines).count)
let numbers = (1...lineCount).map(String.init).joined(separator: "\n")
lineNumberView.font = UIFont.monospacedDigitSystemFont(ofSize: max(11, fontSize - 1), weight: .regular)
lineNumberView.text = numbers
}
}
struct CustomTextEditor: UIViewRepresentable {
@Binding var text: String
let language: String
let colorScheme: ColorScheme
let fontSize: CGFloat
@Binding var isLineWrapEnabled: Bool
let translucentBackgroundEnabled: Bool
func makeUIView(context: Context) -> LineNumberedTextViewContainer {
let container = LineNumberedTextViewContainer()
let textView = container.textView
textView.delegate = context.coordinator
textView.font = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
textView.text = text
textView.autocorrectionType = .no
textView.autocapitalizationType = .none
textView.smartDashesType = .no
textView.smartQuotesType = .no
textView.smartInsertDeleteType = .no
textView.backgroundColor = translucentBackgroundEnabled ? .clear : .systemBackground
textView.textContainer.lineBreakMode = isLineWrapEnabled ? .byWordWrapping : .byClipping
textView.textContainer.widthTracksTextView = isLineWrapEnabled
container.updateLineNumbers(for: text, fontSize: fontSize)
context.coordinator.container = container
context.coordinator.textView = textView
context.coordinator.scheduleHighlightIfNeeded(currentText: text)
return container
}
func updateUIView(_ uiView: LineNumberedTextViewContainer, context: Context) {
let textView = uiView.textView
context.coordinator.parent = self
if textView.text != text {
textView.text = text
}
if textView.font?.pointSize != fontSize {
textView.font = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
}
textView.backgroundColor = translucentBackgroundEnabled ? .clear : .systemBackground
textView.textContainer.lineBreakMode = isLineWrapEnabled ? .byWordWrapping : .byClipping
textView.textContainer.widthTracksTextView = isLineWrapEnabled
uiView.updateLineNumbers(for: text, fontSize: fontSize)
context.coordinator.syncLineNumberScroll()
context.coordinator.scheduleHighlightIfNeeded(currentText: text)
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UITextViewDelegate {
var parent: CustomTextEditor
weak var container: LineNumberedTextViewContainer?
weak var textView: UITextView?
private let highlightQueue = DispatchQueue(label: "NeonVision.iOS.SyntaxHighlight", qos: .userInitiated)
private var pendingHighlight: DispatchWorkItem?
private var lastHighlightedText: String = ""
private var lastLanguage: String?
private var lastColorScheme: ColorScheme?
private var isApplyingHighlight = false
init(_ parent: CustomTextEditor) {
self.parent = parent
}
func scheduleHighlightIfNeeded(currentText: String? = nil) {
guard let textView else { return }
let text = currentText ?? textView.text ?? ""
let lang = parent.language
let scheme = parent.colorScheme
if text == lastHighlightedText && lang == lastLanguage && scheme == lastColorScheme {
return
}
pendingHighlight?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.rehighlight(text: text, language: lang, colorScheme: scheme)
}
pendingHighlight = work
highlightQueue.asyncAfter(deadline: .now() + 0.1, execute: work)
}
private func rehighlight(text: String, language: String, colorScheme: ColorScheme) {
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 attributed = NSMutableAttributedString(
string: text,
attributes: [
.foregroundColor: baseColor,
.font: baseFont
]
)
let colors = SyntaxColors.fromVibrantLightTheme(colorScheme: colorScheme)
let patterns = getSyntaxPatterns(for: language, colors: colors)
for (pattern, color) in patterns {
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else { continue }
let matches = regex.matches(in: text, range: fullRange)
let uiColor = UIColor(color)
for match in matches {
attributed.addAttribute(.foregroundColor, value: uiColor, range: match.range)
}
}
DispatchQueue.main.async { [weak self] in
guard let self, let textView = self.textView else { return }
guard textView.text == text else { return }
let selectedRange = textView.selectedRange
self.isApplyingHighlight = true
textView.attributedText = attributed
textView.selectedRange = selectedRange
textView.typingAttributes = [
.foregroundColor: baseColor,
.font: baseFont
]
self.isApplyingHighlight = false
self.lastHighlightedText = text
self.lastLanguage = language
self.lastColorScheme = colorScheme
self.container?.updateLineNumbers(for: text, fontSize: self.parent.fontSize)
self.syncLineNumberScroll()
}
}
func textViewDidChange(_ textView: UITextView) {
guard !isApplyingHighlight else { return }
parent.text = textView.text
container?.updateLineNumbers(for: textView.text, fontSize: parent.fontSize)
scheduleHighlightIfNeeded(currentText: textView.text)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
syncLineNumberScroll()
}
func syncLineNumberScroll() {
guard let textView, let lineView = container?.lineNumberView else { return }
lineView.contentOffset = CGPoint(x: 0, y: textView.contentOffset.y)
}
}
}
#endif

View file

@ -2,6 +2,9 @@ import SwiftUI
import Combine
import UniformTypeIdentifiers
import Foundation
#if canImport(UIKit)
import UIKit
#endif
struct TabData: Identifiable {
let id = UUID()
@ -202,6 +205,7 @@ class EditorViewModel: ObservableObject {
func saveFileAs(tab: TabData) {
guard let index = tabs.firstIndex(where: { $0.id == tab.id }) else { return }
#if os(macOS)
let panel = NSSavePanel()
panel.nameFieldStringValue = tabs[index].name
let mdType = UTType(filenameExtension: "md") ?? .plainText
@ -227,9 +231,15 @@ class EditorViewModel: ObservableObject {
print("Error saving file: \(error)")
}
}
#else
// iOS/iPadOS: explicit Save As panel is not available here yet.
// Keep document dirty so user can export/share via future document APIs.
print("Save As is currently only available on macOS.")
#endif
}
func openFile() {
#if os(macOS)
let panel = NSOpenPanel()
// Allow opening any file type, including hidden dotfiles like .zshrc
panel.allowedContentTypes = []
@ -255,6 +265,10 @@ class EditorViewModel: ObservableObject {
print("Error opening file: \(error)")
}
}
#else
// iOS/iPadOS: document picker flow can be added here.
print("Open File panel is currently only available on macOS.")
#endif
}
func openFile(url: URL) {
@ -274,6 +288,15 @@ class EditorViewModel: ObservableObject {
print("Error opening file: \(error)")
}
}
func markTabSaved(tabID: UUID, fileURL: URL? = nil) {
guard let index = tabs.firstIndex(where: { $0.id == tabID }) else { return }
if let fileURL {
tabs[index].fileURL = fileURL
tabs[index].name = fileURL.lastPathComponent
}
tabs[index].isDirty = false
}
func wordCount(for text: String) -> Int {
text.components(separatedBy: .whitespacesAndNewlines)

View file

@ -6,6 +6,7 @@
//
#if os(macOS)
import AppKit
final class LineNumberRulerView: NSRulerView {
@ -140,3 +141,4 @@ final class LineNumberRulerView: NSRulerView {
}
}
}
#endif

View file

@ -1,7 +1,12 @@
import SwiftUI
#if canImport(FoundationModels)
import FoundationModels
#endif
#if os(macOS)
import AppKit
#endif
#if os(macOS)
final class AppDelegate: NSObject, NSApplicationDelegate {
weak var viewModel: EditorViewModel?
@ -13,21 +18,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
}
#endif
@main
struct NeonVisionEditorApp: App {
@StateObject private var viewModel = EditorViewModel()
#if os(macOS)
@Environment(\.openWindow) private var openWindow
@State private var showGrokError: Bool = false
@State private var grokErrorMessage: String = ""
@State private var useAppleIntelligence: Bool = true
@State private var appleAIStatus: String = "Apple Intelligence: Checking…"
@State private var appleAIRoundTripMS: Double? = nil
@State private var enableTranslucentWindow: Bool = UserDefaults.standard.bool(forKey: "EnableTranslucentWindow")
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
#endif
@State private var showGrokError: Bool = false
@State private var grokErrorMessage: String = ""
var body: some Scene {
#if os(macOS)
WindowGroup {
ContentView()
.environmentObject(viewModel)
@ -62,7 +70,6 @@ struct NeonVisionEditorApp: App {
}
}
.onAppear {
// Apply initial translucency preference
if let window = NSApp.windows.first {
window.isOpaque = !enableTranslucentWindow
window.backgroundColor = enableTranslucentWindow ? .clear : NSColor.windowBackgroundColor
@ -98,12 +105,12 @@ struct NeonVisionEditorApp: App {
viewModel.addNewTab()
}
.keyboardShortcut("t", modifiers: .command)
Button("Open File...") {
viewModel.openFile()
}
.keyboardShortcut("o", modifiers: .command)
Button("Save") {
if let tab = viewModel.selectedTab {
viewModel.saveFile(tab: tab)
@ -111,20 +118,20 @@ struct NeonVisionEditorApp: App {
}
.keyboardShortcut("s", modifiers: .command)
.disabled(viewModel.selectedTab == nil)
Button("Save As...") {
if let tab = viewModel.selectedTab {
viewModel.saveFileAs(tab: tab)
}
}
.disabled(viewModel.selectedTab == nil)
Button("Rename") {
viewModel.showingRename = true
viewModel.renameText = viewModel.selectedTab?.name ?? "Untitled"
}
.disabled(viewModel.selectedTab == nil)
Button("Close Tab") {
if let tab = viewModel.selectedTab {
viewModel.closeTab(tab: tab)
@ -133,9 +140,9 @@ struct NeonVisionEditorApp: App {
.keyboardShortcut("w", modifiers: .command)
.disabled(viewModel.selectedTab == nil)
}
CommandMenu("Language") {
ForEach(["swift", "python", "javascript", "html", "css", "cpp", "json", "markdown", "standard", "plain"], id: \.self) { lang in
ForEach(["swift", "python", "javascript", "typescript", "java", "kotlin", "go", "ruby", "rust", "sql", "html", "css", "cpp", "csharp", "objective-c", "json", "xml", "yaml", "toml", "ini", "markdown", "bash", "zsh", "powershell", "standard", "plain"], id: \.self) { lang in
Button(lang.capitalized) {
if let tab = viewModel.selectedTab {
viewModel.updateTabLanguage(tab: tab, language: lang)
@ -144,23 +151,69 @@ struct NeonVisionEditorApp: App {
.disabled(viewModel.selectedTab == nil)
}
}
CommandMenu("AI") {
Button("API Settings…") {
NotificationCenter.default.post(name: .showAPISettingsRequested, object: nil)
}
Divider()
Button("Use Apple Intelligence") {
NotificationCenter.default.post(name: .selectAIModelRequested, object: AIModel.appleIntelligence.rawValue)
}
Button("Use Grok") {
NotificationCenter.default.post(name: .selectAIModelRequested, object: AIModel.grok.rawValue)
}
Button("Use OpenAI") {
NotificationCenter.default.post(name: .selectAIModelRequested, object: AIModel.openAI.rawValue)
}
Button("Use Gemini") {
NotificationCenter.default.post(name: .selectAIModelRequested, object: AIModel.gemini.rawValue)
}
Button("Use Anthropic") {
NotificationCenter.default.post(name: .selectAIModelRequested, object: AIModel.anthropic.rawValue)
}
}
CommandMenu("View") {
Toggle("Toggle Sidebar", isOn: $viewModel.showSidebar)
.keyboardShortcut("s", modifiers: [.command, .option])
Button("Toggle Project Structure Sidebar") {
NotificationCenter.default.post(name: .toggleProjectStructureSidebarRequested, object: nil)
}
Toggle("Brain Dump Mode", isOn: $viewModel.isBrainDumpMode)
.keyboardShortcut("d", modifiers: [.command, .shift])
Toggle("Line Wrap", isOn: $viewModel.isLineWrapEnabled)
.keyboardShortcut("l", modifiers: [.command, .option])
Button("Toggle Translucent Window Background") {
NotificationCenter.default.post(name: .toggleTranslucencyRequested, object: !enableTranslucentWindow)
}
}
CommandMenu("Editor") {
Button("Clear Editor") {
NotificationCenter.default.post(name: .clearEditorRequested, object: nil)
}
Button("Toggle Code Completion") {
NotificationCenter.default.post(name: .toggleCodeCompletionRequested, object: nil)
}
Button("Find & Replace") {
NotificationCenter.default.post(name: .showFindReplaceRequested, object: nil)
}
.keyboardShortcut("f", modifiers: .command)
}
CommandMenu("Tools") {
Button("Suggest Code") {
Task {
if let tab = viewModel.selectedTab {
// Choose provider by available tokens (Apple Intelligence preferred) then others
let contentPrefix = String(tab.content.prefix(1000))
let prompt = "Suggest improvements for this \(tab.language) code: \(contentPrefix)"
@ -170,12 +223,10 @@ struct NeonVisionEditorApp: App {
let client: AIClient? = {
#if USE_FOUNDATION_MODELS
// Prefer Apple Intelligence by default
if useAppleIntelligence {
return AIClientFactory.makeClient(for: AIModel.appleIntelligence)
}
#endif
// Fallback order: Grok -> OpenAI -> Gemini -> Apple (if compiled) -> nil
if !grokToken.isEmpty { return AIClientFactory.makeClient(for: .grok, grokAPITokenProvider: { grokToken }) }
if !openAIToken.isEmpty { return AIClientFactory.makeClient(for: .openAI, openAIKeyProvider: { openAIToken }) }
if !geminiToken.isEmpty { return AIClientFactory.makeClient(for: .gemini, geminiKeyProvider: { geminiToken }) }
@ -197,10 +248,10 @@ struct NeonVisionEditorApp: App {
}
.keyboardShortcut("g", modifiers: [.command, .shift])
.disabled(viewModel.selectedTab == nil)
Toggle("Use Apple Intelligence", isOn: $useAppleIntelligence)
}
CommandMenu("Diagnostics") {
Text(appleAIStatus)
Divider()
@ -223,7 +274,6 @@ struct NeonVisionEditorApp: App {
#endif
}
}
.disabled(false)
if let ms = appleAIRoundTripMS {
Text(String(format: "Last round-trip: %.1f ms", ms))
@ -231,6 +281,14 @@ struct NeonVisionEditorApp: App {
}
}
}
#else
WindowGroup {
ContentView()
.environmentObject(viewModel)
.environment(\.showGrokError, $showGrokError)
.environment(\.grokErrorMessage, $grokErrorMessage)
}
#endif
}
}
@ -247,10 +305,9 @@ extension EnvironmentValues {
get { self[ShowGrokErrorKey.self] }
set { self[ShowGrokErrorKey.self] = newValue }
}
var grokErrorMessage: Binding<String> {
get { self[GrokErrorMessageKey.self] }
set { self[GrokErrorMessageKey.self] = newValue }
}
}

View file

@ -1,6 +1,30 @@
import SwiftUI
import AppKit
import Foundation
import UniformTypeIdentifiers
struct PlainTextDocument: FileDocument {
static var readableContentTypes: [UTType] { [.plainText, .text, .sourceCode] }
var text: String
init(text: String = "") {
self.text = text
}
init(configuration: ReadConfiguration) throws {
if let data = configuration.file.regularFileContents,
let decoded = String(data: data, encoding: .utf8) {
text = decoded
} else {
text = ""
}
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let data = text.data(using: .utf8) ?? Data()
return FileWrapper(regularFileWithContents: data)
}
}
struct APISupportSettingsView: View {
@Binding var grokAPIToken: String
@ -108,6 +132,12 @@ extension Notification.Name {
static let caretPositionDidChange = Notification.Name("caretPositionDidChange")
static let pastedText = Notification.Name("pastedText")
static let toggleTranslucencyRequested = Notification.Name("toggleTranslucencyRequested")
static let clearEditorRequested = Notification.Name("clearEditorRequested")
static let toggleCodeCompletionRequested = Notification.Name("toggleCodeCompletionRequested")
static let showFindReplaceRequested = Notification.Name("showFindReplaceRequested")
static let toggleProjectStructureSidebarRequested = Notification.Name("toggleProjectStructureSidebarRequested")
static let showAPISettingsRequested = Notification.Name("showAPISettingsRequested")
static let selectAIModelRequested = Notification.Name("selectAIModelRequested")
}
extension NSRange {

View file

@ -1,5 +1,4 @@
import SwiftUI
import AppKit
import Foundation
struct SidebarView: View {

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>destination</key>
<string>export</string>
<key>manageAppVersionAndBuildNumber</key>
<false/>
<key>signingStyle</key>
<string>automatic</string>
<key>signingCertificate</key>
<string>Apple Distribution</string>
<key>teamID</key>
<string>CS727NF72U</string>
<key>stripSwiftSymbols</key>
<true/>
<key>uploadSymbols</key>
<true/>
<key>compileBitcode</key>
<false/>
</dict>
</plist>

View file

@ -0,0 +1,44 @@
# TestFlight Upload Checklist (iOS + iPadOS)
## 1) Versioning
- In Xcode target settings (`General`): set `Version` (marketing version) for the release.
- Increase `Build` (build number) for every upload.
- Confirm bundle identifier is correct: `h3p.Neon-Vision-Editor`.
## 2) Signing & Capabilities
- Signing style: `Automatic`.
- Team selected: `CS727NF72U`.
- Confirm iPhone + iPad support remains enabled.
- Ensure an `Apple Distribution` certificate exists for your team.
- In Xcode (`Settings` -> `Accounts`), sign in with an App Store Connect user that has provider access to the app/team.
## 3) Archive (Xcode)
- Select scheme: `Neon Vision Editor`.
- Destination: `Any iOS Device (arm64)`.
- Product -> `Archive`.
- In Organizer, verify no critical warnings.
## 4) Export / Upload
- Option A (Xcode Organizer): `Distribute App` -> `App Store Connect` -> `Upload`.
- Option B (CLI export):
- Run: `./scripts/archive_testflight.sh`
- Uses: `release/ExportOptions-TestFlight.plist`
- Upload resulting IPA with Apple Transporter.
## 5) App Store Connect checks
- New build appears in TestFlight (processing may take 5-30 min).
- Fill export compliance if prompted.
- Add internal testers and release notes.
- For external testing: submit Beta App Review.
## 7) If export/upload fails
- `No provider associated with App Store Connect user`: fix Apple ID account/provider access in Xcode Accounts.
- `No profiles for 'h3p.Neon-Vision-Editor' were found`: refresh signing identities/profiles and retry with `-allowProvisioningUpdates`.
- If CLI export still fails, upload directly from Organizer (`Distribute App`) first, then return to CLI flow.
## 6) Pre-flight quality gates
- App launches on iPhone and iPad.
- Open/Save flow works (including iOS document picker).
- New window behavior on macOS remains unaffected.
- No crash on startup with empty documents.
- Basic regression pass: tabs, search/replace, sidebars, translucency toggle.

25
scripts/archive_testflight.sh Executable file
View file

@ -0,0 +1,25 @@
#!/bin/zsh
set -euo pipefail
PROJECT="Neon Vision Editor.xcodeproj"
SCHEME="Neon Vision Editor"
CONFIGURATION="Release"
EXPORT_OPTIONS="release/ExportOptions-TestFlight.plist"
ARCHIVE_PATH="build/NeonVisionEditor.xcarchive"
EXPORT_PATH="build/TestFlightExport"
mkdir -p build
echo "==> Cleaning"
xcodebuild -project "$PROJECT" -scheme "$SCHEME" -configuration "$CONFIGURATION" clean
echo "==> Archiving"
xcodebuild -project "$PROJECT" -scheme "$SCHEME" -configuration "$CONFIGURATION" -destination 'generic/platform=iOS' -archivePath "$ARCHIVE_PATH" archive
echo "==> Exporting IPA"
xcodebuild -allowProvisioningUpdates -exportArchive -archivePath "$ARCHIVE_PATH" -exportPath "$EXPORT_PATH" -exportOptionsPlist "$EXPORT_OPTIONS"
echo "==> Done"
echo "Archive: $ARCHIVE_PATH"
echo "Export: $EXPORT_PATH"
echo "Next: Open Organizer in Xcode and distribute/upload to TestFlight, or use Transporter with the IPA from $EXPORT_PATH"