2026-02-06 18:59:53 +00:00
|
|
|
import SwiftUI
|
|
|
|
|
import Foundation
|
2026-02-07 10:51:52 +00:00
|
|
|
#if os(macOS)
|
|
|
|
|
import AppKit
|
|
|
|
|
#elseif canImport(UIKit)
|
|
|
|
|
import UIKit
|
|
|
|
|
#endif
|
2026-02-06 18:59:53 +00:00
|
|
|
|
|
|
|
|
extension ContentView {
|
2026-02-14 13:24:01 +00:00
|
|
|
func showUpdaterDialog(checkNow: Bool = true) {
|
|
|
|
|
#if os(macOS)
|
|
|
|
|
guard ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution else { return }
|
|
|
|
|
showUpdateDialog = true
|
|
|
|
|
if checkNow {
|
|
|
|
|
Task {
|
|
|
|
|
await appUpdateManager.checkForUpdates(source: .manual)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
func openSettings(tab: String? = nil) {
|
2026-02-12 23:58:32 +00:00
|
|
|
settingsActiveTab = ReleaseRuntimePolicy.settingsTab(from: tab)
|
2026-02-11 10:20:17 +00:00
|
|
|
#if os(macOS)
|
2026-02-13 00:14:15 +00:00
|
|
|
openSettingsAction()
|
2026-02-11 10:20:17 +00:00
|
|
|
#else
|
|
|
|
|
showSettingsSheet = true
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func openAPISettings() {
|
|
|
|
|
openSettings(tab: "ai")
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 10:51:52 +00:00
|
|
|
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):
|
2026-02-20 16:24:27 +00:00
|
|
|
guard !urls.isEmpty else { return }
|
|
|
|
|
var openedCount = 0
|
|
|
|
|
var openedNames: [String] = []
|
|
|
|
|
|
|
|
|
|
for url in urls {
|
|
|
|
|
let didStart = url.startAccessingSecurityScopedResource()
|
|
|
|
|
defer {
|
|
|
|
|
if didStart {
|
|
|
|
|
url.stopAccessingSecurityScopedResource()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
|
|
|
|
recordDiagnostic("iOS import skipped (missing file): \(url.path)")
|
|
|
|
|
continue
|
2026-02-07 10:51:52 +00:00
|
|
|
}
|
2026-02-20 16:24:27 +00:00
|
|
|
|
|
|
|
|
viewModel.openFile(url: url)
|
|
|
|
|
openedCount += 1
|
|
|
|
|
openedNames.append(url.lastPathComponent)
|
2026-02-07 10:51:52 +00:00
|
|
|
}
|
2026-02-20 16:24:27 +00:00
|
|
|
|
|
|
|
|
guard openedCount > 0 else {
|
|
|
|
|
findStatusMessage = "Open failed: selected files are no longer available."
|
|
|
|
|
recordDiagnostic("iOS import failed: no valid files in selection")
|
2026-02-18 22:56:46 +00:00
|
|
|
return
|
|
|
|
|
}
|
2026-02-20 16:24:27 +00:00
|
|
|
|
|
|
|
|
if openedCount == 1, let name = openedNames.first {
|
|
|
|
|
findStatusMessage = "Opened \(name)"
|
|
|
|
|
} else {
|
|
|
|
|
findStatusMessage = "Opened \(openedCount) files"
|
|
|
|
|
}
|
|
|
|
|
recordDiagnostic("iOS import success count: \(openedCount)")
|
2026-02-07 10:51:52 +00:00
|
|
|
case .failure(let error):
|
2026-02-18 22:56:46 +00:00
|
|
|
findStatusMessage = "Open failed: \(userFacingFileError(error))"
|
|
|
|
|
recordDiagnostic("iOS import failed: \(error.localizedDescription)")
|
2026-02-07 10:51:52 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func handleIOSExportResult(_ result: Result<URL, Error>) {
|
|
|
|
|
switch result {
|
|
|
|
|
case .success(let url):
|
|
|
|
|
if let tabID = iosExportTabID {
|
|
|
|
|
viewModel.markTabSaved(tabID: tabID, fileURL: url)
|
|
|
|
|
}
|
2026-02-18 22:56:46 +00:00
|
|
|
findStatusMessage = "Saved to \(url.lastPathComponent)"
|
|
|
|
|
recordDiagnostic("iOS export success: \(url.lastPathComponent)")
|
2026-02-07 10:51:52 +00:00
|
|
|
case .failure(let error):
|
2026-02-18 22:56:46 +00:00
|
|
|
findStatusMessage = "Save failed: \(userFacingFileError(error))"
|
|
|
|
|
recordDiagnostic("iOS export failed: \(error.localizedDescription)")
|
2026-02-07 10:51:52 +00:00
|
|
|
}
|
|
|
|
|
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"
|
|
|
|
|
}
|
2026-02-18 22:56:46 +00:00
|
|
|
|
|
|
|
|
private func userFacingFileError(_ error: Error) -> String {
|
|
|
|
|
let nsError = error as NSError
|
|
|
|
|
if nsError.domain == NSCocoaErrorDomain {
|
|
|
|
|
switch nsError.code {
|
|
|
|
|
case NSUserCancelledError:
|
|
|
|
|
return "Cancelled."
|
|
|
|
|
case NSFileWriteNoPermissionError, NSFileReadNoPermissionError:
|
|
|
|
|
return "No permission for this location."
|
|
|
|
|
case NSFileWriteOutOfSpaceError:
|
|
|
|
|
return "Not enough storage space."
|
|
|
|
|
default:
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nsError.localizedDescription
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
#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"
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 22:20:39 +00:00
|
|
|
func requestClearEditorContent() {
|
|
|
|
|
let hasText = !currentContentBinding.wrappedValue.isEmpty
|
|
|
|
|
if confirmClearEditor && hasText {
|
|
|
|
|
showClearEditorConfirmDialog = true
|
|
|
|
|
} else {
|
|
|
|
|
clearEditorContent()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 10:51:52 +00:00
|
|
|
func toggleSidebarFromToolbar() {
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
if horizontalSizeClass == .compact {
|
|
|
|
|
showCompactSidebarSheet.toggle()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
#endif
|
2026-02-19 14:29:53 +00:00
|
|
|
var transaction = Transaction()
|
|
|
|
|
transaction.animation = nil
|
|
|
|
|
withTransaction(transaction) {
|
|
|
|
|
viewModel.showSidebar.toggle()
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-20 01:16:58 +00:00
|
|
|
func toggleProjectSidebarFromToolbar() {
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
let isPhone = UIDevice.current.userInterfaceIdiom == .phone
|
|
|
|
|
if isPhone || horizontalSizeClass == .compact || horizontalSizeClass == nil {
|
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
showCompactProjectSidebarSheet.toggle()
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
#endif
|
|
|
|
|
var transaction = Transaction()
|
|
|
|
|
transaction.animation = nil
|
|
|
|
|
withTransaction(transaction) {
|
|
|
|
|
showProjectStructureSidebar.toggle()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 10:34:22 +00:00
|
|
|
func dismissKeyboard() {
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 19:20:03 +00:00
|
|
|
func requestCloseTab(_ tab: TabData) {
|
2026-02-12 22:20:39 +00:00
|
|
|
if tab.isDirty && confirmCloseDirtyTab {
|
2026-02-06 19:20:03 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
func findNext() {
|
2026-02-07 10:51:52 +00:00
|
|
|
#if os(macOS)
|
2026-02-06 18:59:53 +00:00
|
|
|
guard !findQuery.isEmpty, let tv = activeEditorTextView() else { return }
|
2026-02-11 10:20:17 +00:00
|
|
|
if let win = tv.window {
|
|
|
|
|
win.makeKeyAndOrderFront(nil)
|
|
|
|
|
win.makeFirstResponder(tv)
|
|
|
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
#else
|
2026-02-12 22:20:39 +00:00
|
|
|
guard !findQuery.isEmpty else { return }
|
|
|
|
|
findStatusMessage = ""
|
|
|
|
|
let source = currentContentBinding.wrappedValue
|
|
|
|
|
let fingerprint = "\(findQuery)|\(findUsesRegex)|\(findCaseSensitive)"
|
|
|
|
|
if fingerprint != iOSLastFindFingerprint {
|
|
|
|
|
iOSLastFindFingerprint = fingerprint
|
|
|
|
|
iOSFindCursorLocation = 0
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 23:58:32 +00:00
|
|
|
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 {
|
2026-02-12 22:20:39 +00:00
|
|
|
findStatusMessage = "Invalid regex pattern"
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
findStatusMessage = "No matches found"
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 23:58:32 +00:00
|
|
|
iOSFindCursorLocation = next.nextCursorLocation
|
2026-02-12 22:20:39 +00:00
|
|
|
NotificationCenter.default.post(
|
|
|
|
|
name: .moveCursorToRange,
|
|
|
|
|
object: nil,
|
|
|
|
|
userInfo: [
|
2026-02-12 23:58:32 +00:00
|
|
|
EditorCommandUserInfo.rangeLocation: next.range.location,
|
|
|
|
|
EditorCommandUserInfo.rangeLength: next.range.length
|
2026-02-12 22:20:39 +00:00
|
|
|
]
|
|
|
|
|
)
|
2026-02-07 10:51:52 +00:00
|
|
|
#endif
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func replaceSelection() {
|
2026-02-07 10:51:52 +00:00
|
|
|
#if os(macOS)
|
2026-02-06 18:59:53 +00:00
|
|
|
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)
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
#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
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func replaceAll() {
|
2026-02-07 10:51:52 +00:00
|
|
|
#if os(macOS)
|
2026-02-06 18:59:53 +00:00
|
|
|
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"
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
#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
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-07 10:51:52 +00:00
|
|
|
#if os(macOS)
|
2026-02-06 18:59:53 +00:00
|
|
|
private func activeEditorTextView() -> NSTextView? {
|
2026-02-11 10:20:17 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
if let tv = window.firstResponder as? NSTextView, tv.isEditable {
|
|
|
|
|
return tv
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
private func findEditorTextView(in view: NSView?) -> NSTextView? {
|
2026-02-06 18:59:53 +00:00
|
|
|
guard let view else { return nil }
|
|
|
|
|
if let scroll = view as? NSScrollView, let tv = scroll.documentView as? NSTextView, tv.isEditable {
|
2026-02-11 10:20:17 +00:00
|
|
|
if tv.identifier?.rawValue == "NeonEditorTextView" {
|
|
|
|
|
return tv
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
if let tv = view as? NSTextView, tv.isEditable {
|
2026-02-11 10:20:17 +00:00
|
|
|
if tv.identifier?.rawValue == "NeonEditorTextView" {
|
|
|
|
|
return tv
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
for subview in view.subviews {
|
2026-02-11 10:20:17 +00:00
|
|
|
if let found = findEditorTextView(in: subview) {
|
2026-02-06 18:59:53 +00:00
|
|
|
return found
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
#endif
|
2026-02-06 18:59:53 +00:00
|
|
|
|
|
|
|
|
func applyWindowTranslucency(_ enabled: Bool) {
|
2026-02-07 10:51:52 +00:00
|
|
|
#if os(macOS)
|
2026-02-06 18:59:53 +00:00
|
|
|
for window in NSApp.windows {
|
|
|
|
|
window.isOpaque = !enabled
|
|
|
|
|
window.backgroundColor = enabled ? .clear : NSColor.windowBackgroundColor
|
2026-02-18 22:56:46 +00:00
|
|
|
// Keep window chrome layout stable across both modes to avoid frame/titlebar jumps.
|
|
|
|
|
window.titlebarAppearsTransparent = true
|
|
|
|
|
window.toolbarStyle = .unified
|
|
|
|
|
window.styleMask.insert(.fullSizeContentView)
|
2026-02-06 18:59:53 +00:00
|
|
|
if #available(macOS 13.0, *) {
|
2026-02-18 22:56:46 +00:00
|
|
|
window.titlebarSeparatorStyle = .none
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
#endif
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func openProjectFolder() {
|
2026-02-07 10:51:52 +00:00
|
|
|
#if os(macOS)
|
2026-02-06 18:59:53 +00:00
|
|
|
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 {
|
2026-02-09 19:49:55 +00:00
|
|
|
setProjectFolder(folderURL)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
#else
|
2026-02-09 19:49:55 +00:00
|
|
|
showProjectFolderPicker = true
|
2026-02-07 10:51:52 +00:00
|
|
|
#endif
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func refreshProjectTree() {
|
|
|
|
|
guard let root = projectRootFolderURL else { return }
|
2026-02-18 22:56:46 +00:00
|
|
|
projectTreeRefreshGeneration &+= 1
|
|
|
|
|
let generation = projectTreeRefreshGeneration
|
|
|
|
|
DispatchQueue.global(qos: .utility).async {
|
|
|
|
|
let nodes = buildProjectTree(at: root)
|
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
guard generation == projectTreeRefreshGeneration else { return }
|
|
|
|
|
guard projectRootFolderURL?.standardizedFileURL == root.standardizedFileURL else { return }
|
|
|
|
|
projectTreeNodes = nodes
|
2026-02-19 14:29:53 +00:00
|
|
|
quickSwitcherProjectFileURLs = projectFileURLs(from: nodes)
|
2026-02-18 22:56:46 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 [] }
|
2026-02-18 22:56:46 +00:00
|
|
|
return readChildren(of: root, recursive: false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func loadProjectTreeChildren(for directory: URL) -> [ProjectTreeNode] {
|
|
|
|
|
readChildren(of: directory, recursive: false)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-09 20:06:03 +00:00
|
|
|
func setProjectFolder(_ folderURL: URL) {
|
2026-02-09 19:49:55 +00:00
|
|
|
#if canImport(UIKit)
|
|
|
|
|
if let previous = projectFolderSecurityURL {
|
|
|
|
|
previous.stopAccessingSecurityScopedResource()
|
|
|
|
|
}
|
|
|
|
|
if folderURL.startAccessingSecurityScopedResource() {
|
|
|
|
|
projectFolderSecurityURL = folderURL
|
|
|
|
|
}
|
|
|
|
|
#endif
|
|
|
|
|
projectRootFolderURL = folderURL
|
2026-02-18 22:56:46 +00:00
|
|
|
projectTreeNodes = []
|
2026-02-19 14:29:53 +00:00
|
|
|
quickSwitcherProjectFileURLs = []
|
2026-02-18 22:56:46 +00:00
|
|
|
refreshProjectTree()
|
2026-02-09 19:49:55 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-18 22:56:46 +00:00
|
|
|
private func readChildren(of directory: URL, recursive: Bool) -> [ProjectTreeNode] {
|
|
|
|
|
if Task.isCancelled { return [] }
|
2026-02-06 18:59:53 +00:00
|
|
|
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 {
|
2026-02-18 22:56:46 +00:00
|
|
|
if Task.isCancelled { break }
|
2026-02-06 18:59:53 +00:00
|
|
|
guard let values = try? url.resourceValues(forKeys: Set(keys)) else { continue }
|
|
|
|
|
if values.isHidden == true { continue }
|
|
|
|
|
let isDirectory = values.isDirectory == true
|
2026-02-18 22:56:46 +00:00
|
|
|
let children = (isDirectory && recursive) ? readChildren(of: url, recursive: true) : []
|
|
|
|
|
nodes.append(
|
|
|
|
|
ProjectTreeNode(
|
|
|
|
|
url: url,
|
|
|
|
|
isDirectory: isDirectory,
|
2026-02-19 09:12:09 +00:00
|
|
|
children: children
|
2026-02-18 22:56:46 +00:00
|
|
|
)
|
|
|
|
|
)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
return nodes
|
|
|
|
|
}
|
2026-02-08 00:06:06 +00:00
|
|
|
|
|
|
|
|
func projectFileURLs(from nodes: [ProjectTreeNode]) -> [URL] {
|
|
|
|
|
var results: [URL] = []
|
2026-02-19 14:29:53 +00:00
|
|
|
var stack = nodes
|
|
|
|
|
while let node = stack.popLast() {
|
2026-02-08 00:06:06 +00:00
|
|
|
if node.isDirectory {
|
2026-02-19 14:29:53 +00:00
|
|
|
stack.append(contentsOf: node.children)
|
2026-02-08 00:06:06 +00:00
|
|
|
} else {
|
|
|
|
|
results.append(node.url)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return results
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|