Neon-Vision-Editor/Neon Vision Editor/UI/ContentView+Actions.swift

492 lines
18 KiB
Swift

import SwiftUI
import Foundation
#if os(macOS)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif
extension ContentView {
func showUpdaterDialog(checkNow: Bool = true) {
#if os(macOS)
guard ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution else { return }
showUpdateDialog = true
if checkNow {
Task {
await appUpdateManager.checkForUpdates(source: .manual)
}
}
#endif
}
func openSettings(tab: String? = nil) {
settingsActiveTab = ReleaseRuntimePolicy.settingsTab(from: tab)
#if os(macOS)
openSettingsAction()
#else
showSettingsSheet = true
#endif
}
func openAPISettings() {
openSettings(tab: "ai")
}
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 requestClearEditorContent() {
let hasText = !currentContentBinding.wrappedValue.isEmpty
if confirmClearEditor && hasText {
showClearEditorConfirmDialog = true
} else {
clearEditorContent()
}
}
func toggleSidebarFromToolbar() {
#if os(iOS)
if horizontalSizeClass == .compact {
showCompactSidebarSheet.toggle()
return
}
#endif
viewModel.showSidebar.toggle()
}
func requestCloseTab(_ tab: TabData) {
if tab.isDirty && confirmCloseDirtyTab {
pendingCloseTabID = tab.id
showUnsavedCloseDialog = true
} else {
viewModel.closeTab(tab: tab)
}
}
func saveAndClosePendingTab() {
guard let pendingCloseTabID,
let tab = viewModel.tabs.first(where: { $0.id == pendingCloseTabID }) else {
self.pendingCloseTabID = nil
return
}
viewModel.saveFile(tab: tab)
if let updated = viewModel.tabs.first(where: { $0.id == pendingCloseTabID }),
!updated.isDirty {
viewModel.closeTab(tab: updated)
self.pendingCloseTabID = nil
} else {
self.pendingCloseTabID = nil
}
}
func discardAndClosePendingTab() {
guard let pendingCloseTabID,
let tab = viewModel.tabs.first(where: { $0.id == pendingCloseTabID }) else {
self.pendingCloseTabID = nil
return
}
viewModel.closeTab(tab: tab)
self.pendingCloseTabID = nil
}
func findNext() {
#if os(macOS)
guard !findQuery.isEmpty, let tv = activeEditorTextView() else { return }
if let win = tv.window {
win.makeKeyAndOrderFront(nil)
win.makeFirstResponder(tv)
NSApp.activate(ignoringOtherApps: true)
}
findStatusMessage = ""
let ns = tv.string as NSString
let start = tv.selectedRange().upperBound
let forwardRange = NSRange(location: start, length: max(0, ns.length - start))
let wrapRange = NSRange(location: 0, length: max(0, start))
if findUsesRegex {
guard let regex = try? NSRegularExpression(pattern: findQuery, options: findCaseSensitive ? [] : [.caseInsensitive]) else {
findStatusMessage = "Invalid regex pattern"
NSSound.beep()
return
}
let forwardMatch = regex.firstMatch(in: tv.string, options: [], range: forwardRange)
let wrapMatch = regex.firstMatch(in: tv.string, options: [], range: wrapRange)
if let match = forwardMatch ?? wrapMatch {
tv.setSelectedRange(match.range)
tv.scrollRangeToVisible(match.range)
} else {
findStatusMessage = "No matches found"
NSSound.beep()
}
} else {
let opts: NSString.CompareOptions = findCaseSensitive ? [] : [.caseInsensitive]
if let range = ns.range(of: findQuery, options: opts, range: forwardRange).toOptional() ?? ns.range(of: findQuery, options: opts, range: wrapRange).toOptional() {
tv.setSelectedRange(range)
tv.scrollRangeToVisible(range)
} else {
findStatusMessage = "No matches found"
NSSound.beep()
}
}
#else
guard !findQuery.isEmpty else { return }
findStatusMessage = ""
let source = currentContentBinding.wrappedValue
let fingerprint = "\(findQuery)|\(findUsesRegex)|\(findCaseSensitive)"
if fingerprint != iOSLastFindFingerprint {
iOSLastFindFingerprint = fingerprint
iOSFindCursorLocation = 0
}
guard let next = ReleaseRuntimePolicy.nextFindMatch(
in: source,
query: findQuery,
useRegex: findUsesRegex,
caseSensitive: findCaseSensitive,
cursorLocation: iOSFindCursorLocation
) else {
if findUsesRegex, (try? NSRegularExpression(pattern: findQuery, options: findCaseSensitive ? [] : [.caseInsensitive])) == nil {
findStatusMessage = "Invalid regex pattern"
return
}
findStatusMessage = "No matches found"
return
}
iOSFindCursorLocation = next.nextCursorLocation
NotificationCenter.default.post(
name: .moveCursorToRange,
object: nil,
userInfo: [
EditorCommandUserInfo.rangeLocation: next.range.location,
EditorCommandUserInfo.rangeLength: next.range.length
]
)
#endif
}
func replaceSelection() {
#if os(macOS)
guard let tv = activeEditorTextView() else { return }
let sel = tv.selectedRange()
guard sel.length > 0 else { return }
let selectedText = (tv.string as NSString).substring(with: sel)
if findUsesRegex {
guard let regex = try? NSRegularExpression(pattern: findQuery, options: findCaseSensitive ? [] : [.caseInsensitive]) else {
findStatusMessage = "Invalid regex pattern"
NSSound.beep()
return
}
let fullSelected = NSRange(location: 0, length: (selectedText as NSString).length)
let replacement = regex.stringByReplacingMatches(in: selectedText, options: [], range: fullSelected, withTemplate: replaceQuery)
tv.insertText(replacement, replacementRange: sel)
} 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
if findUsesRegex {
guard let regex = try? NSRegularExpression(pattern: findQuery, options: findCaseSensitive ? [] : [.caseInsensitive]) else {
findStatusMessage = "Invalid regex pattern"
NSSound.beep()
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"
NSSound.beep()
return
}
let updated = regex.stringByReplacingMatches(in: original, options: [], range: fullRange, withTemplate: replaceQuery)
tv.string = updated
tv.didChangeText()
findStatusMessage = "Replaced \(count) matches"
} else {
let opts: NSString.CompareOptions = findCaseSensitive ? [] : [.caseInsensitive]
let nsOriginal = original as NSString
var count = 0
var searchLocation = 0
while searchLocation < nsOriginal.length {
let r = nsOriginal.range(of: findQuery, options: opts, range: NSRange(location: searchLocation, length: nsOriginal.length - searchLocation))
if r.location == NSNotFound { break }
count += 1
searchLocation = max(r.location + max(r.length, 1), searchLocation + 1)
}
guard count > 0 else {
findStatusMessage = "No matches found"
NSSound.beep()
return
}
let updated = nsOriginal.replacingOccurrences(of: findQuery, with: replaceQuery, options: opts, range: NSRange(location: 0, length: nsOriginal.length))
tv.string = updated
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? {
var candidates: [NSWindow] = []
if let main = NSApp.mainWindow { candidates.append(main) }
if let key = NSApp.keyWindow, key !== NSApp.mainWindow { candidates.append(key) }
candidates.append(contentsOf: NSApp.windows.filter { $0.isVisible })
for window in candidates {
if window.isKind(of: NSPanel.self) { continue }
if window.styleMask.contains(.docModalWindow) { continue }
if let found = findEditorTextView(in: window.contentView) {
return found
}
if let tv = window.firstResponder as? NSTextView, tv.isEditable {
return tv
}
}
return nil
}
private func findEditorTextView(in view: NSView?) -> NSTextView? {
guard let view else { return nil }
if let scroll = view as? NSScrollView, let tv = scroll.documentView as? NSTextView, tv.isEditable {
if tv.identifier?.rawValue == "NeonEditorTextView" {
return tv
}
}
if let tv = view as? NSTextView, tv.isEditable {
if tv.identifier?.rawValue == "NeonEditorTextView" {
return tv
}
}
for subview in view.subviews {
if let found = findEditorTextView(in: subview) {
return found
}
}
return nil
}
#endif
func applyWindowTranslucency(_ enabled: Bool) {
#if os(macOS)
for window in NSApp.windows {
window.isOpaque = !enabled
window.backgroundColor = enabled ? .clear : NSColor.windowBackgroundColor
window.titlebarAppearsTransparent = enabled
if enabled {
// Keep toolbar material blended with the titlebar instead of rendering as a separate solid strip.
window.toolbarStyle = .unified
window.toolbar?.showsBaselineSeparator = false
window.styleMask.insert(.fullSizeContentView)
} else {
window.toolbar?.showsBaselineSeparator = true
window.styleMask.remove(.fullSizeContentView)
}
if #available(macOS 13.0, *) {
window.titlebarSeparatorStyle = enabled ? .none : .automatic
}
}
#endif
}
func openProjectFolder() {
#if os(macOS)
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.canCreateDirectories = false
panel.showsHiddenFiles = false
if panel.runModal() == .OK, let folderURL = panel.url {
setProjectFolder(folderURL)
}
#else
showProjectFolderPicker = true
#endif
}
func refreshProjectTree() {
guard let root = projectRootFolderURL else { return }
projectTreeNodes = buildProjectTree(at: root)
}
func openProjectFile(url: URL) {
if let existing = viewModel.tabs.first(where: { $0.fileURL?.standardizedFileURL == url.standardizedFileURL }) {
viewModel.selectedTabID = existing.id
return
}
viewModel.openFile(url: url)
}
private func buildProjectTree(at root: URL) -> [ProjectTreeNode] {
var isDir: ObjCBool = false
guard FileManager.default.fileExists(atPath: root.path, isDirectory: &isDir), isDir.boolValue else { return [] }
return readChildren(of: root)
}
func setProjectFolder(_ folderURL: URL) {
#if canImport(UIKit)
if let previous = projectFolderSecurityURL {
previous.stopAccessingSecurityScopedResource()
}
if folderURL.startAccessingSecurityScopedResource() {
projectFolderSecurityURL = folderURL
}
#endif
projectRootFolderURL = folderURL
projectTreeNodes = buildProjectTree(at: folderURL)
}
private func readChildren(of directory: URL) -> [ProjectTreeNode] {
let fm = FileManager.default
let keys: [URLResourceKey] = [.isDirectoryKey, .isHiddenKey, .nameKey]
guard let urls = try? fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: keys, options: [.skipsPackageDescendants, .skipsSubdirectoryDescendants]) else {
return []
}
let sorted = urls.sorted { $0.lastPathComponent.localizedCaseInsensitiveCompare($1.lastPathComponent) == .orderedAscending }
var nodes: [ProjectTreeNode] = []
for url in sorted {
guard let values = try? url.resourceValues(forKeys: Set(keys)) else { continue }
if values.isHidden == true { continue }
let isDirectory = values.isDirectory == true
let children = isDirectory ? readChildren(of: url) : []
nodes.append(ProjectTreeNode(url: url, isDirectory: isDirectory, children: children))
}
return nodes
}
func projectFileURLs(from nodes: [ProjectTreeNode]) -> [URL] {
var results: [URL] = []
for node in nodes {
if node.isDirectory {
results.append(contentsOf: projectFileURLs(from: node.children))
} else {
results.append(node.url)
}
}
return results
}
}