mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 13:27:16 +00:00
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:
parent
1736888047
commit
0945b23b01
14 changed files with 1012 additions and 50 deletions
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Release"
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
final class LineNumberRulerView: NSRulerView {
|
||||
|
|
@ -140,3 +141,4 @@ final class LineNumberRulerView: NSRulerView {
|
|||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import SwiftUI
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
struct SidebarView: View {
|
||||
|
|
|
|||
24
release/ExportOptions-TestFlight.plist
Normal file
24
release/ExportOptions-TestFlight.plist
Normal 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>
|
||||
44
release/TestFlight-Upload-Checklist.md
Normal file
44
release/TestFlight-Upload-Checklist.md
Normal 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
25
scripts/archive_testflight.sh
Executable 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"
|
||||
Loading…
Reference in a new issue