548 lines
19 KiB
Swift
548 lines
19 KiB
Swift
import SwiftUI
|
|
import Combine
|
|
|
|
public struct ClipboardNotice: Identifiable, Equatable {
|
|
public let id = UUID()
|
|
public let filename: String
|
|
}
|
|
|
|
@MainActor
|
|
public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
|
public static let minimumSearchCharacters = 3
|
|
|
|
@Published public var config: SyncConfig
|
|
@Published public var isSyncing = false
|
|
@Published public var lastSyncTime: Date?
|
|
@Published public var syncStatus: String = "Ready"
|
|
@Published public var pendingChanges = 0
|
|
@Published public var uploadProgress: Double? = nil
|
|
@Published public private(set) var recentUploads: [AttachmentRecord] = []
|
|
@Published public private(set) var isLoadingRecentUploads = false
|
|
@Published public private(set) var recentUploadsError: String?
|
|
@Published public private(set) var clipboardNotice: ClipboardNotice?
|
|
@Published public var searchQuery = ""
|
|
@Published public private(set) var searchResults: [SearchResult] = []
|
|
@Published public private(set) var isSearching = false
|
|
@Published public private(set) var searchError: String?
|
|
@Published public var selectedSearchIndex = 0
|
|
|
|
public var cancellables = Set<AnyCancellable>()
|
|
|
|
private let configStore = ConfigStore.shared
|
|
private var apiClient: APIClient
|
|
private var syncEngine: SyncEngine?
|
|
private var uploadManager: UploadManager
|
|
private var folderWatcher: FolderWatcher?
|
|
private var syncTimer: Timer?
|
|
private var searchTask: Task<Void, Never>?
|
|
|
|
private var settingsWindow: NSWindow?
|
|
private var uploadsWindow: NSWindow?
|
|
private var dropZoneWindow: NSWindow?
|
|
|
|
public override init() {
|
|
let config = ConfigStore.shared.load()
|
|
self.config = config
|
|
self.apiClient = APIClient(baseURL: config.serverBaseURL ?? URL(string: "http://localhost:8080")!, apiToken: config.apiToken)
|
|
self.uploadManager = UploadManager(client: apiClient, config: config)
|
|
self.syncEngine = SyncEngine(client: apiClient, config: config)
|
|
super.init()
|
|
|
|
Task {
|
|
await setupSync()
|
|
}
|
|
Task {
|
|
await refreshRecentUploads()
|
|
}
|
|
}
|
|
|
|
public func updateConfig(_ newConfig: SyncConfig) {
|
|
config = newConfig
|
|
|
|
Task {
|
|
await configStore.save(newConfig)
|
|
|
|
if let url = newConfig.serverBaseURL {
|
|
await apiClient.setAPIToken(newConfig.apiToken)
|
|
apiClient = APIClient(baseURL: url, apiToken: newConfig.apiToken)
|
|
uploadManager = UploadManager(client: apiClient, config: newConfig)
|
|
syncEngine = SyncEngine(client: apiClient, config: newConfig)
|
|
resetSearch()
|
|
await refreshRecentUploads()
|
|
await setupSync()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setupSync() async {
|
|
// Stop existing watcher
|
|
folderWatcher?.stop()
|
|
syncTimer?.invalidate()
|
|
|
|
guard config.autoSync, let syncFolder = config.syncFolder else {
|
|
syncStatus = "Auto-sync disabled"
|
|
return
|
|
}
|
|
|
|
// Setup folder watcher
|
|
let watcher = FolderWatcher(folderPath: syncFolder)
|
|
watcher.delegate = self
|
|
watcher.start()
|
|
self.folderWatcher = watcher
|
|
|
|
// Setup periodic sync
|
|
syncTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(config.syncInterval), repeats: true) { [weak self] _ in
|
|
Task { [weak self] in
|
|
await self?.performSync()
|
|
}
|
|
}
|
|
|
|
// Initial sync
|
|
await performSync()
|
|
}
|
|
|
|
public func performSync() async {
|
|
guard !isSyncing else { return }
|
|
guard config.syncFolder != nil else {
|
|
syncStatus = "No sync folder configured"
|
|
return
|
|
}
|
|
|
|
isSyncing = true
|
|
syncStatus = "Syncing..."
|
|
|
|
do {
|
|
let engine = syncEngine ?? SyncEngine(client: apiClient, config: config)
|
|
syncEngine = engine
|
|
try await engine.performSync()
|
|
|
|
lastSyncTime = Date()
|
|
syncStatus = "Synced"
|
|
} catch {
|
|
syncStatus = "Sync failed: \(error.localizedDescription)"
|
|
}
|
|
|
|
isSyncing = false
|
|
}
|
|
|
|
// MARK: - Window Management
|
|
|
|
public func showSettings() {
|
|
if let window = settingsWindow {
|
|
present(window)
|
|
return
|
|
}
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 520, height: 640),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.title = "Cairnquire Settings"
|
|
window.titlebarAppearsTransparent = true
|
|
window.isReleasedWhenClosed = false
|
|
window.delegate = self
|
|
window.contentView = NSHostingView(rootView: SettingsView(config: config, onSave: { [weak self, weak window] newConfig in
|
|
self?.updateConfig(newConfig)
|
|
window?.close()
|
|
}))
|
|
window.center()
|
|
|
|
settingsWindow = window
|
|
present(window)
|
|
}
|
|
|
|
public func showUploads() {
|
|
if let window = uploadsWindow {
|
|
present(window)
|
|
return
|
|
}
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 400, height: 500),
|
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.title = "Uploaded Files"
|
|
window.isReleasedWhenClosed = false
|
|
window.delegate = self
|
|
window.contentView = NSHostingView(rootView: UploadsListView(client: apiClient))
|
|
window.center()
|
|
|
|
uploadsWindow = window
|
|
present(window)
|
|
}
|
|
|
|
public func showDropZone() {
|
|
if let window = dropZoneWindow {
|
|
present(window)
|
|
return
|
|
}
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 300, height: 250),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.title = "Upload File"
|
|
window.isReleasedWhenClosed = false
|
|
window.delegate = self
|
|
window.contentView = NSHostingView(rootView: DropZoneView(uploadManager: uploadManager))
|
|
window.center()
|
|
|
|
dropZoneWindow = window
|
|
present(window)
|
|
}
|
|
|
|
public func windowWillClose(_ notification: Notification) {
|
|
guard let window = notification.object as? NSWindow else { return }
|
|
|
|
if window === settingsWindow {
|
|
settingsWindow = nil
|
|
} else if window === uploadsWindow {
|
|
uploadsWindow = nil
|
|
} else if window === dropZoneWindow {
|
|
dropZoneWindow = nil
|
|
}
|
|
}
|
|
|
|
private func present(_ window: NSWindow) {
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
window.makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
public func quickUpload() {
|
|
let panel = NSOpenPanel()
|
|
panel.canChooseFiles = true
|
|
panel.canChooseDirectories = false
|
|
panel.allowsMultipleSelection = true
|
|
|
|
guard panel.runModal() == .OK else { return }
|
|
|
|
Task {
|
|
await uploadURLList(panel.urls)
|
|
}
|
|
}
|
|
|
|
public func uploadFiles(_ urls: [URL]) {
|
|
Task {
|
|
await uploadURLList(urls)
|
|
}
|
|
}
|
|
|
|
private func uploadURLList(_ urls: [URL]) async {
|
|
guard !urls.isEmpty else { return }
|
|
|
|
let total = Double(urls.count)
|
|
for (index, url) in urls.enumerated() {
|
|
let baseProgress = Double(index) / total
|
|
let fileWeight = 1 / total
|
|
uploadProgress = baseProgress
|
|
syncStatus = "Uploading \(url.lastPathComponent)"
|
|
|
|
let onProgress: UploadProgressHandler = { [weak self] fileProgress in
|
|
let combinedProgress = baseProgress + min(max(fileProgress, 0), 1) * fileWeight
|
|
Task { @MainActor in
|
|
self?.uploadProgress = combinedProgress
|
|
}
|
|
}
|
|
|
|
do {
|
|
let result = try await uploadFileResolvingDuplicates(url, onProgress: onProgress)
|
|
uploadProgress = Double(index + 1) / total
|
|
syncStatus = "Uploaded \(result.filename)"
|
|
clipboardNotice = ClipboardNotice(filename: result.filename)
|
|
await refreshRecentUploads()
|
|
} catch {
|
|
syncStatus = "Upload failed: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
uploadProgress = nil
|
|
}
|
|
|
|
private func uploadFileResolvingDuplicates(
|
|
_ url: URL,
|
|
onProgress: UploadProgressHandler?
|
|
) async throws -> UploadResult {
|
|
var duplicateAction: UploadDuplicateAction?
|
|
|
|
while true {
|
|
do {
|
|
return try await uploadManager.uploadFile(
|
|
url,
|
|
duplicateAction: duplicateAction,
|
|
onProgress: onProgress
|
|
)
|
|
} catch let error as APIError where error.isUploadConflict {
|
|
guard let action = promptForUploadDuplicate(error, filename: url.lastPathComponent) else {
|
|
throw UploadCancelledError()
|
|
}
|
|
duplicateAction = action
|
|
}
|
|
}
|
|
}
|
|
|
|
private func uploadDataResolvingDuplicates(_ data: Data, filename: String) async throws -> UploadResult {
|
|
var duplicateAction: UploadDuplicateAction?
|
|
|
|
while true {
|
|
do {
|
|
return try await uploadManager.uploadData(
|
|
data,
|
|
filename: filename,
|
|
duplicateAction: duplicateAction
|
|
)
|
|
} catch let error as APIError where error.isUploadConflict {
|
|
guard let action = promptForUploadDuplicate(error, filename: filename) else {
|
|
throw UploadCancelledError()
|
|
}
|
|
duplicateAction = action
|
|
}
|
|
}
|
|
}
|
|
|
|
public func refreshRecentUploads() async {
|
|
guard !isLoadingRecentUploads else { return }
|
|
|
|
isLoadingRecentUploads = true
|
|
defer { isLoadingRecentUploads = false }
|
|
|
|
do {
|
|
recentUploads = Array(try await apiClient.listAttachments().prefix(3))
|
|
recentUploadsError = nil
|
|
} catch {
|
|
recentUploadsError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
public func updateSearchQuery(_ query: String) {
|
|
searchQuery = query
|
|
selectedSearchIndex = 0
|
|
searchTask?.cancel()
|
|
|
|
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard trimmed.count >= Self.minimumSearchCharacters else {
|
|
searchResults = []
|
|
isSearching = false
|
|
searchError = nil
|
|
return
|
|
}
|
|
|
|
isSearching = true
|
|
searchError = nil
|
|
searchTask = Task { [weak self] in
|
|
do {
|
|
try await Task.sleep(for: .milliseconds(300))
|
|
await self?.performSearch(for: trimmed)
|
|
} catch {
|
|
await MainActor.run {
|
|
if self?.isCurrentSearchQuery(trimmed) == true {
|
|
self?.isSearching = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public func clearSearch() {
|
|
resetSearch()
|
|
}
|
|
|
|
public func moveSearchSelection(by delta: Int) {
|
|
guard !searchResults.isEmpty else { return }
|
|
selectedSearchIndex = min(max(selectedSearchIndex + delta, 0), searchResults.count - 1)
|
|
}
|
|
|
|
public func openSelectedSearchResult() {
|
|
guard searchResults.indices.contains(selectedSearchIndex) else { return }
|
|
openSearchResult(searchResults[selectedSearchIndex])
|
|
}
|
|
|
|
public func openSearchResult(_ result: SearchResult) {
|
|
guard let url = documentURL(for: result) else { return }
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
|
|
private func performSearch(for query: String) async {
|
|
do {
|
|
let results = try await apiClient.searchDocuments(query: liveSearchQuery(for: query))
|
|
guard !Task.isCancelled else { return }
|
|
guard isCurrentSearchQuery(query) else { return }
|
|
searchResults = Array(results.prefix(8))
|
|
selectedSearchIndex = min(selectedSearchIndex, max(searchResults.count - 1, 0))
|
|
searchError = nil
|
|
} catch {
|
|
guard !Task.isCancelled else { return }
|
|
guard isCurrentSearchQuery(query) else { return }
|
|
searchResults = []
|
|
searchError = error.localizedDescription
|
|
}
|
|
isSearching = false
|
|
}
|
|
|
|
private func isCurrentSearchQuery(_ query: String) -> Bool {
|
|
let trimmed = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed == query && trimmed.count >= Self.minimumSearchCharacters
|
|
}
|
|
|
|
private func resetSearch() {
|
|
searchTask?.cancel()
|
|
searchQuery = ""
|
|
searchResults = []
|
|
isSearching = false
|
|
searchError = nil
|
|
selectedSearchIndex = 0
|
|
}
|
|
|
|
private func liveSearchQuery(for query: String) -> String {
|
|
let terms = query
|
|
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty }
|
|
|
|
guard !terms.isEmpty else { return query }
|
|
return terms.map { "\($0)*" }.joined(separator: " ")
|
|
}
|
|
|
|
private func documentURL(for result: SearchResult) -> URL? {
|
|
guard let baseURL = config.serverBaseURL else { return nil }
|
|
let trimmedPath = result.path.hasSuffix(".md") ? String(result.path.dropLast(3)) : result.path
|
|
var allowed = CharacterSet.urlPathAllowed
|
|
allowed.remove(charactersIn: "?#")
|
|
guard let encodedPath = trimmedPath.addingPercentEncoding(withAllowedCharacters: allowed) else {
|
|
return nil
|
|
}
|
|
return URL(string: "docs/\(encodedPath)", relativeTo: baseURL)?.absoluteURL
|
|
}
|
|
|
|
public func createNote() {
|
|
switch config.effectiveNewNoteAction {
|
|
case .clipboard:
|
|
createNoteFromClipboard()
|
|
case .editor:
|
|
createNoteInEditor()
|
|
}
|
|
}
|
|
|
|
public func createNoteFromClipboard() {
|
|
guard let text = NSPasteboard.general.string(forType: .string) else {
|
|
syncStatus = "Clipboard is empty"
|
|
return
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
let result = try await uploadManager.createNote(from: text)
|
|
syncStatus = "Created note: \(result.title)"
|
|
} catch {
|
|
syncStatus = "Failed to create note: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
|
|
private func createNoteInEditor() {
|
|
let draftURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cairnquire-\(UUID().uuidString)")
|
|
.appendingPathExtension("md")
|
|
let completionURL = draftURL.appendingPathExtension("done")
|
|
|
|
do {
|
|
try Data().write(to: draftURL, options: .atomic)
|
|
try launchTerminalEditor(draftURL: draftURL, completionURL: completionURL)
|
|
syncStatus = "Waiting for $EDITOR to exit"
|
|
} catch {
|
|
try? FileManager.default.removeItem(at: draftURL)
|
|
syncStatus = "Failed to open $EDITOR: \(error.localizedDescription)"
|
|
return
|
|
}
|
|
|
|
Task { [weak self] in
|
|
await self?.uploadEditorDraft(draftURL: draftURL, completionURL: completionURL)
|
|
}
|
|
}
|
|
|
|
private func uploadEditorDraft(draftURL: URL, completionURL: URL) async {
|
|
defer {
|
|
try? FileManager.default.removeItem(at: draftURL)
|
|
try? FileManager.default.removeItem(at: completionURL)
|
|
}
|
|
|
|
while !FileManager.default.fileExists(atPath: completionURL.path) {
|
|
try? await Task.sleep(for: .milliseconds(500))
|
|
}
|
|
|
|
do {
|
|
let data = try Data(contentsOf: draftURL)
|
|
guard !data.isEmpty else {
|
|
syncStatus = "Editor note was empty; nothing uploaded"
|
|
return
|
|
}
|
|
|
|
let filename = Self.editorNoteFilename()
|
|
let result = try await uploadDataResolvingDuplicates(data, filename: filename)
|
|
syncStatus = "Uploaded \(result.filename)"
|
|
clipboardNotice = ClipboardNotice(filename: result.filename)
|
|
await refreshRecentUploads()
|
|
} catch {
|
|
syncStatus = "Failed to upload editor note: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
|
|
private func launchTerminalEditor(draftURL: URL, completionURL: URL) throws {
|
|
let draftPath = Self.shellQuote(draftURL.path)
|
|
let completionPath = Self.shellQuote(completionURL.path)
|
|
let shellCommand = "trap '/usr/bin/touch \(completionPath)' EXIT; editor=\"${EDITOR:-${VISUAL:-vi}}\"; eval \"$editor \(draftPath)\""
|
|
let script = "tell application \"Terminal\" to do script \"\(Self.appleScriptEscape(shellCommand))\""
|
|
|
|
let process = Process()
|
|
let errorPipe = Pipe()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
|
process.arguments = ["-e", "tell application \"Terminal\" to activate", "-e", script]
|
|
process.standardError = errorPipe
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
|
|
guard process.terminationStatus == 0 else {
|
|
let data = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
|
let message = String(data: data, encoding: .utf8)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
throw NSError(
|
|
domain: "CairnquireSync",
|
|
code: Int(process.terminationStatus),
|
|
userInfo: [NSLocalizedDescriptionKey: message ?? "Terminal could not be opened"]
|
|
)
|
|
}
|
|
}
|
|
|
|
private static func shellQuote(_ value: String) -> String {
|
|
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
|
}
|
|
|
|
private static func appleScriptEscape(_ value: String) -> String {
|
|
value
|
|
.replacingOccurrences(of: "\\", with: "\\\\")
|
|
.replacingOccurrences(of: "\"", with: "\\\"")
|
|
}
|
|
|
|
private static func editorNoteFilename() -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
formatter.dateFormat = "yyyy-MM-dd HH-mm-ss"
|
|
return "Note \(formatter.string(from: Date())).md"
|
|
}
|
|
}
|
|
|
|
extension MenuBarController: FolderWatcherDelegate {
|
|
public nonisolated func folderWatcher(_ watcher: FolderWatcher, didDetectChanges paths: [String]) {
|
|
Task { @MainActor in
|
|
pendingChanges += paths.count
|
|
syncStatus = "\(pendingChanges) pending changes"
|
|
await performSync()
|
|
pendingChanges = 0
|
|
}
|
|
}
|
|
}
|