mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 13:27:16 +00:00
485 lines
16 KiB
Swift
485 lines
16 KiB
Swift
#if os(macOS)
|
|
import SwiftUI
|
|
#if canImport(FoundationModels)
|
|
import FoundationModels
|
|
#endif
|
|
|
|
|
|
|
|
/// MARK: - Types
|
|
|
|
struct NeonVisionMacAppCommands: Commands {
|
|
let activeEditorViewModel: () -> EditorViewModel
|
|
let hasActiveEditorWindow: () -> Bool
|
|
let openNewWindow: () -> Void
|
|
let openAIDiagnosticsWindow: () -> Void
|
|
let postWindowCommand: (_ name: Notification.Name, _ object: Any?) -> Void
|
|
let isUpdaterEnabled: Bool
|
|
let recentFilesProvider: () -> [RecentFilesStore.Item]
|
|
let clearRecentFiles: () -> Void
|
|
|
|
@Binding var useAppleIntelligence: Bool
|
|
@Binding var appleAIStatus: String
|
|
@Binding var appleAIRoundTripMS: Double?
|
|
@Binding var showGrokError: Bool
|
|
@Binding var grokErrorMessage: String
|
|
|
|
private static let languageOptions = [
|
|
"swift", "python", "javascript", "typescript", "php", "java", "kotlin", "go", "ruby",
|
|
"rust", "cobol", "dotenv", "proto", "graphql", "rst", "nginx", "sql", "html",
|
|
"expressionengine", "css", "c", "cpp", "csharp", "objective-c", "json", "xml", "yaml",
|
|
"toml", "csv", "ini", "vim", "log", "ipynb", "markdown", "tex", "bash", "zsh", "powershell",
|
|
"standard", "plain"
|
|
]
|
|
|
|
private var appleAIStatusMenuLabel: String {
|
|
if appleAIStatus.contains("Ready") { return "AI: Ready" }
|
|
if appleAIStatus.contains("Checking") { return "AI: Checking" }
|
|
if appleAIStatus.contains("Unavailable") { return "AI: Unavailable" }
|
|
if appleAIStatus.contains("Error") { return "AI: Error" }
|
|
return "AI: Status"
|
|
}
|
|
|
|
@CommandsBuilder
|
|
var body: some Commands {
|
|
appSettingsCommands
|
|
fileCommands
|
|
languageCommands
|
|
aiCommands
|
|
viewCommands
|
|
findCommands
|
|
editorCommands
|
|
toolsCommands
|
|
diagnosticsCommands
|
|
helpCommands
|
|
}
|
|
|
|
private var hasSelectedTab: Bool {
|
|
activeEditorViewModel().selectedTab != nil
|
|
}
|
|
|
|
private var hasSavableSelectedTab: Bool {
|
|
guard let selectedTab = activeEditorViewModel().selectedTab else { return false }
|
|
return !selectedTab.isReadOnlyPreview
|
|
}
|
|
|
|
private func post(_ name: Notification.Name, object: Any? = nil) {
|
|
postWindowCommand(name, object)
|
|
}
|
|
|
|
private var recentFiles: [RecentFilesStore.Item] {
|
|
recentFilesProvider()
|
|
}
|
|
|
|
@CommandsBuilder
|
|
private var appSettingsCommands: some Commands {
|
|
CommandGroup(before: .appSettings) {
|
|
if isUpdaterEnabled {
|
|
Button("Check for Updates…") {
|
|
post(.showUpdaterRequested, object: true)
|
|
}
|
|
Divider()
|
|
}
|
|
}
|
|
}
|
|
|
|
@CommandsBuilder
|
|
private var fileCommands: some Commands {
|
|
CommandGroup(replacing: .newItem) {
|
|
Button("New Window") {
|
|
openNewWindow()
|
|
}
|
|
.keyboardShortcut("n", modifiers: .command)
|
|
|
|
Button("New Tab") {
|
|
activeEditorViewModel().addNewTab()
|
|
}
|
|
.keyboardShortcut("t", modifiers: .command)
|
|
}
|
|
|
|
CommandGroup(after: .newItem) {
|
|
Button("Open File…") {
|
|
activeEditorViewModel().openFile()
|
|
}
|
|
.keyboardShortcut("o", modifiers: .command)
|
|
|
|
Button("Open Folder…") {
|
|
post(.openProjectFolderRequested)
|
|
}
|
|
.keyboardShortcut("o", modifiers: [.command, .shift])
|
|
}
|
|
|
|
CommandMenu("Open Recent") {
|
|
if recentFiles.isEmpty {
|
|
Button("No Recent Files") {}
|
|
.disabled(true)
|
|
} else {
|
|
ForEach(Array(recentFiles.prefix(10))) { item in
|
|
Button {
|
|
post(.openRecentFileRequested, object: item.url)
|
|
} label: {
|
|
if item.isPinned {
|
|
Label(item.title, systemImage: "star.fill")
|
|
} else {
|
|
Text(item.title)
|
|
}
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Clear Unpinned Recents") {
|
|
clearRecentFiles()
|
|
}
|
|
}
|
|
}
|
|
|
|
CommandGroup(replacing: .saveItem) {
|
|
Button("Save") {
|
|
let current = activeEditorViewModel()
|
|
if let tab = current.selectedTab {
|
|
current.saveFile(tabID: tab.id)
|
|
}
|
|
}
|
|
.keyboardShortcut("s", modifiers: .command)
|
|
.disabled(!hasSavableSelectedTab)
|
|
|
|
Button("Save As…") {
|
|
let current = activeEditorViewModel()
|
|
if let tab = current.selectedTab {
|
|
current.saveFileAs(tabID: tab.id)
|
|
}
|
|
}
|
|
.keyboardShortcut("s", modifiers: [.command, .shift])
|
|
.disabled(!hasSavableSelectedTab)
|
|
|
|
Button("Rename") {
|
|
let current = activeEditorViewModel()
|
|
current.showingRename = true
|
|
current.renameText = current.selectedTab?.name ?? "Untitled"
|
|
}
|
|
.disabled(!hasSelectedTab)
|
|
|
|
Divider()
|
|
|
|
Button("Close Tab") {
|
|
post(.closeSelectedTabRequested)
|
|
}
|
|
.keyboardShortcut("w", modifiers: .command)
|
|
.disabled(!hasActiveEditorWindow() || !hasSelectedTab)
|
|
}
|
|
}
|
|
|
|
@CommandsBuilder
|
|
private var languageCommands: some Commands {
|
|
CommandMenu("Language") {
|
|
ForEach(Self.languageOptions, id: \.self) { language in
|
|
Button(languageLabel(for: language)) {
|
|
let current = activeEditorViewModel()
|
|
if let tab = current.selectedTab {
|
|
current.updateTabLanguage(tabID: tab.id, language: language)
|
|
}
|
|
}
|
|
.disabled(!hasSelectedTab)
|
|
}
|
|
}
|
|
}
|
|
|
|
@CommandsBuilder
|
|
private var aiCommands: some Commands {
|
|
CommandMenu("AI") {
|
|
Button("API Settings…") {
|
|
post(.showAPISettingsRequested)
|
|
}
|
|
}
|
|
}
|
|
|
|
@CommandsBuilder
|
|
private var viewCommands: some Commands {
|
|
CommandGroup(after: .toolbar) {
|
|
Button("Toggle Sidebar") {
|
|
post(.toggleSidebarRequested)
|
|
}
|
|
.keyboardShortcut("s", modifiers: [.command, .option])
|
|
|
|
Button("Toggle Project Structure Sidebar") {
|
|
post(.toggleProjectStructureSidebarRequested)
|
|
}
|
|
|
|
Button("Brain Dump Mode") {
|
|
post(.toggleBrainDumpModeRequested)
|
|
}
|
|
.keyboardShortcut("d", modifiers: [.command, .shift])
|
|
|
|
Button("Toggle Translucent Window Background") {
|
|
let next = !UserDefaults.standard.bool(forKey: "EnableTranslucentWindow")
|
|
UserDefaults.standard.set(next, forKey: "EnableTranslucentWindow")
|
|
post(.toggleTranslucencyRequested, object: next)
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button {
|
|
post(.showWelcomeTourRequested)
|
|
} label: {
|
|
Label("Show Welcome Tour", systemImage: "sparkles.rectangle.stack")
|
|
}
|
|
}
|
|
}
|
|
|
|
@CommandsBuilder
|
|
private var findCommands: some Commands {
|
|
CommandMenu("Find") {
|
|
Button("Find…") {
|
|
post(.showFindReplaceRequested)
|
|
}
|
|
.keyboardShortcut("f", modifiers: .command)
|
|
|
|
Button("Find Next") {
|
|
post(.findNextRequested)
|
|
}
|
|
.keyboardShortcut("g", modifiers: .command)
|
|
|
|
Button("Find in Files…") {
|
|
post(.showFindInFilesRequested)
|
|
}
|
|
.keyboardShortcut("f", modifiers: [.command, .shift])
|
|
}
|
|
}
|
|
|
|
@CommandsBuilder
|
|
private var editorCommands: some Commands {
|
|
CommandMenu("Editor") {
|
|
Button("Quick Open…") {
|
|
post(.showQuickSwitcherRequested)
|
|
}
|
|
.keyboardShortcut("p", modifiers: .command)
|
|
|
|
Button("Clear Editor") {
|
|
post(.clearEditorRequested)
|
|
}
|
|
|
|
Button("Add Next Match") {
|
|
post(.addNextMatchRequested)
|
|
}
|
|
.keyboardShortcut("d", modifiers: .command)
|
|
.disabled(!hasSelectedTab)
|
|
|
|
Divider()
|
|
|
|
Button("Toggle Vim Mode") {
|
|
post(.toggleVimModeRequested)
|
|
}
|
|
.keyboardShortcut("v", modifiers: [.command, .shift])
|
|
}
|
|
}
|
|
|
|
@CommandsBuilder
|
|
private var toolsCommands: some Commands {
|
|
CommandMenu("Tools") {
|
|
Button("Suggest Code") {
|
|
suggestCode()
|
|
}
|
|
.keyboardShortcut("g", modifiers: [.command, .shift])
|
|
.disabled(!hasSelectedTab)
|
|
|
|
Toggle("Use Apple Intelligence", isOn: $useAppleIntelligence)
|
|
}
|
|
}
|
|
|
|
@CommandsBuilder
|
|
private var diagnosticsCommands: some Commands {
|
|
CommandMenu("Diag") {
|
|
Text(appleAIStatusMenuLabel)
|
|
Divider()
|
|
|
|
Button("Open AI Activity Log") {
|
|
openAIDiagnosticsWindow()
|
|
}
|
|
.keyboardShortcut("l", modifiers: [.command, .shift])
|
|
|
|
Button("Inspect Whitespace Scalars at Caret") {
|
|
post(.inspectWhitespaceScalarsRequested)
|
|
}
|
|
.keyboardShortcut("u", modifiers: [.command, .shift])
|
|
|
|
Divider()
|
|
|
|
Button("Run AI Check") {
|
|
runAICheck()
|
|
}
|
|
|
|
if let roundTripMS = appleAIRoundTripMS {
|
|
Text(String(format: "RTT: %.1f ms", roundTripMS))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
@CommandsBuilder
|
|
private var helpCommands: some Commands {
|
|
CommandGroup(replacing: .help) {
|
|
Button("Neon Vision Editor Help") {
|
|
post(.showEditorHelpRequested)
|
|
}
|
|
.keyboardShortcut("?", modifiers: .command)
|
|
|
|
Button("Show Welcome Tour") {
|
|
post(.showWelcomeTourRequested)
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Support Neon Vision Editor…") {
|
|
post(.showSupportPromptRequested)
|
|
}
|
|
.keyboardShortcut("h", modifiers: [.command, .option, .shift])
|
|
}
|
|
}
|
|
|
|
private func runAICheck() {
|
|
Task {
|
|
AIActivityLog.record("Manual AI health check started.", source: "Diag")
|
|
#if USE_FOUNDATION_MODELS && canImport(FoundationModels)
|
|
do {
|
|
let start = Date()
|
|
_ = try await AppleFM.appleFMHealthCheck()
|
|
let end = Date()
|
|
appleAIStatus = "Apple Intelligence: Ready"
|
|
appleAIRoundTripMS = end.timeIntervalSince(start) * 1000.0
|
|
AIActivityLog.record(
|
|
"AI health check succeeded (\(String(format: "%.1f", appleAIRoundTripMS ?? 0)) ms).",
|
|
source: "Diag"
|
|
)
|
|
} catch {
|
|
appleAIStatus = "Apple Intelligence: Error — \(error.localizedDescription)"
|
|
appleAIRoundTripMS = nil
|
|
AIActivityLog.record(
|
|
"AI health check failed: \(error.localizedDescription)",
|
|
level: .error,
|
|
source: "Diag"
|
|
)
|
|
}
|
|
#else
|
|
appleAIStatus = "Apple Intelligence: Unavailable (build without USE_FOUNDATION_MODELS)"
|
|
appleAIRoundTripMS = nil
|
|
AIActivityLog.record(
|
|
"AI health check unavailable (built without USE_FOUNDATION_MODELS).",
|
|
level: .warning,
|
|
source: "Diag"
|
|
)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private func suggestCode() {
|
|
Task {
|
|
let current = activeEditorViewModel()
|
|
guard let tab = current.selectedTab else { return }
|
|
|
|
let contentPrefix = String(tab.content.prefix(1000))
|
|
let prompt = "Suggest improvements for this \(tab.language) code: \(contentPrefix)"
|
|
|
|
AIActivityLog.record("Suggest Code requested for tab '\(tab.name)'.", source: "Suggest")
|
|
|
|
let grokToken = SecureTokenStore.token(for: .grok)
|
|
let openAIToken = SecureTokenStore.token(for: .openAI)
|
|
let geminiToken = SecureTokenStore.token(for: .gemini)
|
|
let anthropicToken = SecureTokenStore.token(for: .anthropic)
|
|
|
|
var providerLabel = "Unknown"
|
|
let client: AIClient? = {
|
|
#if USE_FOUNDATION_MODELS && canImport(FoundationModels)
|
|
if useAppleIntelligence {
|
|
providerLabel = "Apple Intelligence"
|
|
return AIClientFactory.makeClient(for: .appleIntelligence)
|
|
}
|
|
#endif
|
|
if !grokToken.isEmpty {
|
|
providerLabel = "Grok"
|
|
return AIClientFactory.makeClient(for: .grok, grokAPITokenProvider: { grokToken })
|
|
}
|
|
if !openAIToken.isEmpty {
|
|
providerLabel = "OpenAI"
|
|
return AIClientFactory.makeClient(for: .openAI, openAIKeyProvider: { openAIToken })
|
|
}
|
|
if !geminiToken.isEmpty {
|
|
providerLabel = "Gemini"
|
|
return AIClientFactory.makeClient(for: .gemini, geminiKeyProvider: { geminiToken })
|
|
}
|
|
if !anthropicToken.isEmpty {
|
|
providerLabel = "Anthropic"
|
|
return AIClientFactory.makeClient(for: .anthropic, anthropicKeyProvider: { anthropicToken })
|
|
}
|
|
#if USE_FOUNDATION_MODELS && canImport(FoundationModels)
|
|
providerLabel = "Apple Intelligence (fallback)"
|
|
return AIClientFactory.makeClient(for: .appleIntelligence)
|
|
#else
|
|
return nil
|
|
#endif
|
|
}()
|
|
|
|
guard let client else {
|
|
let message = "No AI provider configured."
|
|
grokErrorMessage = message
|
|
showGrokError = true
|
|
AIActivityLog.record(message, level: .error, source: "Suggest")
|
|
return
|
|
}
|
|
|
|
AIActivityLog.record("Suggest Code using \(providerLabel).", source: "Suggest")
|
|
var aggregated = ""
|
|
for await chunk in client.streamSuggestions(prompt: prompt) {
|
|
aggregated += chunk
|
|
}
|
|
let trimmed = aggregated.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed.isEmpty {
|
|
AIActivityLog.record(
|
|
"Suggest Code returned an empty response from \(providerLabel).",
|
|
level: .warning,
|
|
source: "Suggest"
|
|
)
|
|
return
|
|
}
|
|
|
|
current.updateTabContent(
|
|
tabID: tab.id,
|
|
content: tab.content + "\n\n// AI Suggestion:\n" + aggregated
|
|
)
|
|
AIActivityLog.record("Suggest Code completed (\(aggregated.count) chars).", source: "Suggest")
|
|
}
|
|
}
|
|
|
|
private func languageLabel(for language: String) -> String {
|
|
switch language {
|
|
case "php": return "PHP"
|
|
case "cobol": return "COBOL"
|
|
case "dotenv": return "Dotenv"
|
|
case "proto": return "Proto"
|
|
case "graphql": return "GraphQL"
|
|
case "rst": return "reStructuredText"
|
|
case "nginx": return "Nginx"
|
|
case "objective-c": return "Objective-C"
|
|
case "csharp": return "C#"
|
|
case "c": return "C"
|
|
case "cpp": return "C++"
|
|
case "json": return "JSON"
|
|
case "xml": return "XML"
|
|
case "yaml": return "YAML"
|
|
case "toml": return "TOML"
|
|
case "csv": return "CSV"
|
|
case "ini": return "INI"
|
|
case "sql": return "SQL"
|
|
case "vim": return "Vim"
|
|
case "log": return "Log"
|
|
case "ipynb": return "Jupyter Notebook"
|
|
case "tex": return "TeX"
|
|
case "html": return "HTML"
|
|
case "expressionengine": return "ExpressionEngine"
|
|
case "css": return "CSS"
|
|
case "standard": return "Standard"
|
|
default: return language.capitalized
|
|
}
|
|
}
|
|
}
|
|
#endif
|