From 0945b23b01db4bf8aae1b5088aed1007cf8a373d Mon Sep 17 00:00:00 2001 From: h3p Date: Sat, 7 Feb 2026 11:51:52 +0100 Subject: [PATCH] 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 --- Neon Vision Editor.xcodeproj/project.pbxproj | 8 +- .../xcschemes/Neon Vision Editor.xcscheme | 2 +- Neon Vision Editor/ContentView+Actions.swift | 148 ++++++++- Neon Vision Editor/ContentView+Toolbar.swift | 288 +++++++++++++++++- Neon Vision Editor/ContentView.swift | 144 ++++++++- Neon Vision Editor/EditorTextView.swift | 216 ++++++++++++- Neon Vision Editor/EditorViewModel.swift | 23 ++ Neon Vision Editor/LineNumberRulerView.swift | 2 + Neon Vision Editor/NeonVisionEditorApp.swift | 105 +++++-- Neon Vision Editor/PanelsAndHelpers.swift | 32 +- Neon Vision Editor/SidebarViews.swift | 1 - release/ExportOptions-TestFlight.plist | 24 ++ release/TestFlight-Upload-Checklist.md | 44 +++ scripts/archive_testflight.sh | 25 ++ 14 files changed, 1012 insertions(+), 50 deletions(-) create mode 100644 release/ExportOptions-TestFlight.plist create mode 100644 release/TestFlight-Upload-Checklist.md create mode 100755 scripts/archive_testflight.sh diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index 6733d94..bdd6953 100644 --- a/Neon Vision Editor.xcodeproj/project.pbxproj +++ b/Neon Vision Editor.xcodeproj/project.pbxproj @@ -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 = ""; diff --git a/Neon Vision Editor.xcodeproj/xcshareddata/xcschemes/Neon Vision Editor.xcscheme b/Neon Vision Editor.xcodeproj/xcshareddata/xcschemes/Neon Vision Editor.xcscheme index ee839cb..dd84796 100644 --- a/Neon Vision Editor.xcodeproj/xcshareddata/xcschemes/Neon Vision Editor.xcscheme +++ b/Neon Vision Editor.xcodeproj/xcshareddata/xcschemes/Neon Vision Editor.xcscheme @@ -31,7 +31,7 @@ shouldAutocreateTestPlan = "YES"> ) { + 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) { + 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() { diff --git a/Neon Vision Editor/ContentView+Toolbar.swift b/Neon Vision Editor/ContentView+Toolbar.swift index 4c41ac6..652de69 100644 --- a/Neon Vision Editor/ContentView+Toolbar.swift +++ b/Neon Vision Editor/ContentView+Toolbar.swift @@ -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 } } diff --git a/Neon Vision Editor/ContentView.swift b/Neon Vision Editor/ContentView.swift index 40aad8a..d61c623 100644 --- a/Neon Vision Editor/ContentView.swift +++ b/Neon Vision Editor/ContentView.swift @@ -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 } } diff --git a/Neon Vision Editor/EditorTextView.swift b/Neon Vision Editor/EditorTextView.swift index 2a8b596..f0efc17 100644 --- a/Neon Vision Editor/EditorTextView.swift +++ b/Neon Vision Editor/EditorTextView.swift @@ -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 diff --git a/Neon Vision Editor/EditorViewModel.swift b/Neon Vision Editor/EditorViewModel.swift index 6e6faf3..0987682 100644 --- a/Neon Vision Editor/EditorViewModel.swift +++ b/Neon Vision Editor/EditorViewModel.swift @@ -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) diff --git a/Neon Vision Editor/LineNumberRulerView.swift b/Neon Vision Editor/LineNumberRulerView.swift index b0061fd..26b74f7 100644 --- a/Neon Vision Editor/LineNumberRulerView.swift +++ b/Neon Vision Editor/LineNumberRulerView.swift @@ -6,6 +6,7 @@ // +#if os(macOS) import AppKit final class LineNumberRulerView: NSRulerView { @@ -140,3 +141,4 @@ final class LineNumberRulerView: NSRulerView { } } } +#endif diff --git a/Neon Vision Editor/NeonVisionEditorApp.swift b/Neon Vision Editor/NeonVisionEditorApp.swift index 0966dd4..ae7d866 100644 --- a/Neon Vision Editor/NeonVisionEditorApp.swift +++ b/Neon Vision Editor/NeonVisionEditorApp.swift @@ -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 { get { self[GrokErrorMessageKey.self] } set { self[GrokErrorMessageKey.self] = newValue } } } - diff --git a/Neon Vision Editor/PanelsAndHelpers.swift b/Neon Vision Editor/PanelsAndHelpers.swift index 07b4f5e..c60947f 100644 --- a/Neon Vision Editor/PanelsAndHelpers.swift +++ b/Neon Vision Editor/PanelsAndHelpers.swift @@ -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 { diff --git a/Neon Vision Editor/SidebarViews.swift b/Neon Vision Editor/SidebarViews.swift index 4a2c652..2506f44 100644 --- a/Neon Vision Editor/SidebarViews.swift +++ b/Neon Vision Editor/SidebarViews.swift @@ -1,5 +1,4 @@ import SwiftUI -import AppKit import Foundation struct SidebarView: View { diff --git a/release/ExportOptions-TestFlight.plist b/release/ExportOptions-TestFlight.plist new file mode 100644 index 0000000..cc14b67 --- /dev/null +++ b/release/ExportOptions-TestFlight.plist @@ -0,0 +1,24 @@ + + + + + method + app-store-connect + destination + export + manageAppVersionAndBuildNumber + + signingStyle + automatic + signingCertificate + Apple Distribution + teamID + CS727NF72U + stripSwiftSymbols + + uploadSymbols + + compileBitcode + + + diff --git a/release/TestFlight-Upload-Checklist.md b/release/TestFlight-Upload-Checklist.md new file mode 100644 index 0000000..e6f61e1 --- /dev/null +++ b/release/TestFlight-Upload-Checklist.md @@ -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. diff --git a/scripts/archive_testflight.sh b/scripts/archive_testflight.sh new file mode 100755 index 0000000..e53ef99 --- /dev/null +++ b/scripts/archive_testflight.sh @@ -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"