diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 0fffbeeaf..cdd8e7a2d 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -21,6 +21,9 @@ 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */; }; 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */; }; A11A00000000000000000002 /* AnalyticsDatabaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A00000000000000000001 /* AnalyticsDatabaseTests.swift */; }; + 2E9B00000000000000000007 /* ZeppelinDataLayerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9B00000000000000000006 /* ZeppelinDataLayerTests.swift */; }; + 2E9B00000000000000000009 /* SearchIndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9B00000000000000000008 /* SearchIndexTests.swift */; }; + 2E9B0000000000000000000B /* AppSearchServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9B0000000000000000000A /* AppSearchServiceTests.swift */; }; A62300000000000000000002 /* AudioBufferConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62300000000000000000001 /* AudioBufferConverterTests.swift */; }; D1A600000000000000000202 /* SpeakerTurnMergingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A600000000000000000201 /* SpeakerTurnMergingTests.swift */; }; F1BD00000000000000000002 /* StripThinkingTagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1BD00000000000000000001 /* StripThinkingTagsTests.swift */; }; @@ -69,6 +72,9 @@ 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = ""; }; 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.swift; sourceTree = ""; }; A11A00000000000000000001 /* AnalyticsDatabaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsDatabaseTests.swift; sourceTree = ""; }; + 2E9B00000000000000000006 /* ZeppelinDataLayerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZeppelinDataLayerTests.swift; sourceTree = ""; }; + 2E9B00000000000000000008 /* SearchIndexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchIndexTests.swift; sourceTree = ""; }; + 2E9B0000000000000000000A /* AppSearchServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSearchServiceTests.swift; sourceTree = ""; }; A62300000000000000000001 /* AudioBufferConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioBufferConverterTests.swift; sourceTree = ""; }; D1A600000000000000000201 /* SpeakerTurnMergingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeakerTurnMergingTests.swift; sourceTree = ""; }; F1BD00000000000000000001 /* StripThinkingTagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripThinkingTagsTests.swift; sourceTree = ""; }; @@ -165,6 +171,9 @@ 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */, 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */, A11A00000000000000000001 /* AnalyticsDatabaseTests.swift */, + 2E9B00000000000000000006 /* ZeppelinDataLayerTests.swift */, + 2E9B00000000000000000008 /* SearchIndexTests.swift */, + 2E9B0000000000000000000A /* AppSearchServiceTests.swift */, A62300000000000000000001 /* AudioBufferConverterTests.swift */, D1A600000000000000000201 /* SpeakerTurnMergingTests.swift */, F1BD00000000000000000001 /* StripThinkingTagsTests.swift */, @@ -233,6 +242,7 @@ 7CE006BC2E80EBE600DDCCD6 /* AppUpdater */, 7C9A71012F58B00000FB7CAF /* TranscribeCpp */, 7C5AF14A2F15041600DE21B0 /* MediaRemoteAdapter */, + 2E9B00000000000000000004 /* ZeppelinEmbed */, ); productName = FluidVoice; productReference = 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */; @@ -254,6 +264,7 @@ name = FluidDictationIntegrationTests; packageProductDependencies = ( 803000000000000000000004 /* MediaRemoteAdapter */, + 2E9B00000000000000000005 /* ZeppelinEmbed */, ); productName = FluidDictationIntegrationTests; productReference = 7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */; @@ -292,6 +303,7 @@ 7C3697872ED70F9C005874CE /* XCRemoteSwiftPackageReference "DynamicNotchKit" */, 7C9A71002F58B00000FB7CAF /* XCRemoteSwiftPackageReference "transcribe-cpp-swift" */, 7C5AF1492F15041600DE21B0 /* XCRemoteSwiftPackageReference "mediaremote-adapter" */, + 2E9B00000000000000000003 /* XCRemoteSwiftPackageReference "zeppelin-embed" */, ); preferredProjectObjectVersion = 77; productRefGroup = 7C078D902E3B339200FB7CAC /* Products */; @@ -343,6 +355,9 @@ 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */, 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */, A11A00000000000000000002 /* AnalyticsDatabaseTests.swift in Sources */, + 2E9B00000000000000000007 /* ZeppelinDataLayerTests.swift in Sources */, + 2E9B00000000000000000009 /* SearchIndexTests.swift in Sources */, + 2E9B0000000000000000000B /* AppSearchServiceTests.swift in Sources */, A62300000000000000000002 /* AudioBufferConverterTests.swift in Sources */, D1A600000000000000000202 /* SpeakerTurnMergingTests.swift in Sources */, F1BD00000000000000000002 /* StripThinkingTagsTests.swift in Sources */, @@ -711,6 +726,14 @@ kind = branch; }; }; + 2E9B00000000000000000003 /* XCRemoteSwiftPackageReference "zeppelin-embed" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/zepdb/zeppelin-embed.git"; + requirement = { + kind = exactVersion; + version = 0.4.1; + }; + }; 7C9A71002F58B00000FB7CAF /* XCRemoteSwiftPackageReference "transcribe-cpp-swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/altic-dev/transcribe-cpp-swift.git"; @@ -755,6 +778,16 @@ package = 7CE006BB2E80EBE600DDCCD6 /* XCRemoteSwiftPackageReference "AppUpdater" */; productName = AppUpdater; }; + 2E9B00000000000000000004 /* ZeppelinEmbed */ = { + isa = XCSwiftPackageProductDependency; + package = 2E9B00000000000000000003 /* XCRemoteSwiftPackageReference "zeppelin-embed" */; + productName = ZeppelinEmbed; + }; + 2E9B00000000000000000005 /* ZeppelinEmbed */ = { + isa = XCSwiftPackageProductDependency; + package = 2E9B00000000000000000003 /* XCRemoteSwiftPackageReference "zeppelin-embed" */; + productName = ZeppelinEmbed; + }; 7C9A71012F58B00000FB7CAF /* TranscribeCpp */ = { isa = XCSwiftPackageProductDependency; package = 7C9A71002F58B00000FB7CAF /* XCRemoteSwiftPackageReference "transcribe-cpp-swift" */; diff --git a/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ffc9c4f57..86f7739ec 100644 --- a/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c0b937cf1a9f455cc47d65c349124c6a9e9f173f41a1aa7809c1c0d65cbf4229", + "originHash" : "f7ad30d968aec3ed22705eb42b7b2c993f57c298942da95e0e9f00806ac08045", "pins" : [ { "identity" : "appupdater", @@ -153,6 +153,15 @@ "revision" : "8b4a38dc994a110abaec8a400615567bd996105f", "version" : "0.12.0" } + }, + { + "identity" : "zeppelin-embed", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zepdb/zeppelin-embed.git", + "state" : { + "revision" : "69b488e3a697ae22ebe2115c69ce2989b505dfe2", + "version" : "0.4.1" + } } ], "version" : 3 diff --git a/Package.resolved b/Package.resolved index b573ca29a..5ccb7bb21 100644 --- a/Package.resolved +++ b/Package.resolved @@ -170,6 +170,15 @@ "revision" : "8b4a38dc994a110abaec8a400615567bd996105f", "version" : "0.12.0" } + }, + { + "identity" : "zeppelin-embed", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zepdb/zeppelin-embed.git", + "state" : { + "revision" : "69b488e3a697ae22ebe2115c69ce2989b505dfe2", + "version" : "0.4.1" + } } ], "version" : 2 diff --git a/Package.swift b/Package.swift index 8bac338dd..7d0e9f856 100644 --- a/Package.swift +++ b/Package.swift @@ -14,6 +14,7 @@ let package = Package( .package(url: "https://github.com/mxcl/PromiseKit", from: "6.0.0"), .package(url: "https://github.com/altic-dev/DynamicNotchKit.git", branch: "main"), .package(url: "https://github.com/altic-dev/transcribe-cpp-swift.git", exact: "0.1.2"), + .package(url: "https://github.com/zepdb/zeppelin-embed.git", exact: "0.4.1"), ], targets: [ .target( @@ -32,6 +33,7 @@ let package = Package( "PromiseKit", "DynamicNotchKit", .product(name: "TranscribeCpp", package: "transcribe-cpp-swift"), + .product(name: "ZeppelinEmbed", package: "zeppelin-embed"), ], linkerSettings: [ .linkedLibrary("sqlite3"), diff --git a/Sources/Fluid/AppDelegate.swift b/Sources/Fluid/AppDelegate.swift index e9eefc8cd..2219052c0 100644 --- a/Sources/Fluid/AppDelegate.swift +++ b/Sources/Fluid/AppDelegate.swift @@ -50,6 +50,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele SettingsStore.shared.bootstrapOnboardingState(isTrueFirstOpen: isTrueFirstOpen) AnalyticsService.shared.bootstrap() + SearchIndexCoordinator.shared.start() // Check for updates automatically if enabled (initial check on launch) self.checkForUpdatesAutomatically() @@ -85,12 +86,35 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele DebugLogger.shared.info("Application will terminate", source: "AppDelegate") self.shutdownPrivateAIRuntimeForTermination() self.shutdownASRRuntimeForTermination() + self.closeZeppelinForTermination() LocalAPIServer.shared.stop() // Clean up the update check timer self.updateCheckTimer?.invalidate() self.updateCheckTimer = nil } + /// Short deadline: the index is rebuilt from its source stores, so a timeout + /// costs a log replay at startup and nothing else. + private func closeZeppelinForTermination() { + var didClose = false + Task { + await FluidZeppelinRoot.shared.closeAll() + didClose = true + } + + let deadline = Date().addingTimeInterval(2) + while !didClose, Date() < deadline { + RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) + } + + if !didClose { + DebugLogger.shared.warning( + "Timed out closing Zeppelin namespaces during termination", + source: "AppDelegate" + ) + } + } + private func shutdownASRRuntimeForTermination() { var didFinishShutdown = false Task { @MainActor in diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index d1cd30a8f..e84e10cc5 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -302,6 +302,15 @@ struct ContentView: View { @State private var settingsNavigation = SettingsNavigationState() @State private var settingsSearchQuery = "" @State private var settingsSearchScrollRequest = 0 + /// A settings row chosen from the sidebar search, highlighted like a settings + /// search match until the user searches or leaves settings. + @State private var settingsRevealTarget: SettingsSearchTarget? + @ObservedObject private var appSearch = AppSearchService.shared + @State private var appSearchCursor: AppSearchHit.Target? + /// A dictionary, vocabulary, punctuation or prompt row chosen from the sidebar + /// search. The screen it belongs to opens that row and clears this. + @State private var appSearchRevealTarget: AppSearchHit.Target? + @State private var appSearchExpanded: Set = [] @State private var isHelpEntryHovered = false @State private var isSettingsEntryHovered = false @@ -1094,6 +1103,7 @@ struct ContentView: View { private func resetSettingsSearch() { self.settingsSearchQuery = "" + self.settingsRevealTarget = nil self.settingsSearchScrollRequest += 1 } @@ -1304,6 +1314,46 @@ struct ContentView: View { } private var appSidebarView: some View { + VStack(spacing: 0) { + SidebarSearchField( + text: self.$appSearch.query, + placeholder: "Search", + isActive: !self.settingsNavigation.isPresented, + onCommand: self.handleAppSearchCommand + ) + .frame(height: 24) + .padding(.horizontal, self.theme.metrics.spacing.md) + .padding(.top, self.theme.metrics.spacing.sm) + .padding(.bottom, self.theme.metrics.spacing.sm) + + if self.isAppSearchActive { + AppSearchResultsView( + service: self.appSearch, + cursor: self.$appSearchCursor, + expanded: self.$appSearchExpanded, + open: self.open(searchHit:) + ) + } else { + self.appSidebarSections + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 0) { + self.helpEntryButton + self.settingsEntryButton + } + } + .onChange(of: self.appSearch.groups) { + self.appSearchCursor = nil + self.appSearchExpanded = [] + } + } + + private var isAppSearchActive: Bool { + !self.appSearch.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var appSidebarSections: some View { List(selection: self.$selectedSidebarItem) { Section { self.sidebarNavigationLink(.voiceEngine, title: "Voice Engine", systemImage: "waveform") @@ -1339,12 +1389,51 @@ struct ContentView: View { .listStyle(.sidebar) .accentColor(self.theme.palette.accent) .animation(nil, value: self.selectedSidebarItem) - .safeAreaInset(edge: .bottom, spacing: 0) { - VStack(spacing: 0) { - self.helpEntryButton - self.settingsEntryButton - } + } + + // MARK: - App search + + /// Up and Down move the cursor through the visible rows, Return opens the + /// cursor or the first row. Anything else is left to the field. + private func handleAppSearchCommand(_ selector: Selector) -> Bool { + let hits = AppSearchResultsView.visibleHits(self.appSearch.groups, expanded: self.appSearchExpanded) + guard !hits.isEmpty else { return false } + let position = hits.firstIndex { $0.target == self.appSearchCursor } + switch selector { + case #selector(NSResponder.moveDown(_:)): + self.appSearchCursor = hits[min((position ?? -1) + 1, hits.count - 1)].target + case #selector(NSResponder.moveUp(_:)): + self.appSearchCursor = hits[max((position ?? hits.count) - 1, 0)].target + case #selector(NSResponder.insertNewline(_:)): + self.open(searchHit: hits[position ?? 0]) + default: + return false } + return true + } + + private func open(searchHit hit: AppSearchHit) { + switch hit.target { + case let .history(id): + TranscriptionHistoryStore.shared.selectedEntryID = id + self.navigateToApp(.history) + case let .transcript(id): + FileTranscriptionHistoryStore.shared.selectedEntryID = id + self.navigateToApp(.meetingTools) + case let .chat(id): + self.commandModeService.switchToChat(id: id) + self.navigateToApp(.commandMode) + case .dictionaryEntry, .vocabulary, .punctuation: + self.appSearchRevealTarget = hit.target + self.navigateToApp(.customDictionary) + case .prompt: + self.appSearchRevealTarget = hit.target + self.navigateToApp(.cleanupStyles) + case let .settings(target): + self.openSettings(target.section) + self.settingsRevealTarget = target + } + self.appSearch.query = "" } private var settingsSidebarView: some View { @@ -1377,14 +1466,18 @@ struct ContentView: View { .help("Back to FluidVoice") .accessibilityLabel("Back to FluidVoice") - SettingsSearchField(text: Binding( - get: { self.settingsSearchQuery }, - set: { self.updateSettingsSearchQuery($0) } - ), isActive: self.settingsNavigation.isPresented) - .frame(height: 24) - .padding(.horizontal, self.theme.metrics.spacing.md) - .padding(.top, self.theme.metrics.spacing.xs) - .padding(.bottom, self.theme.metrics.spacing.sm) + SidebarSearchField( + text: Binding( + get: { self.settingsSearchQuery }, + set: { self.updateSettingsSearchQuery($0) } + ), + placeholder: "Search Settings", + isActive: self.settingsNavigation.isPresented + ) + .frame(height: 24) + .padding(.horizontal, self.theme.metrics.spacing.md) + .padding(.top, self.theme.metrics.spacing.xs) + .padding(.bottom, self.theme.metrics.spacing.sm) List(selection: Binding( get: { self.settingsNavigation.selectedSection }, @@ -1428,7 +1521,10 @@ struct ContentView: View { } private var settingsSearchResults: [SettingsSearchResult] { - self.availableSettingsSearchResults(for: self.settingsSearchQuery) + if let target = self.settingsRevealTarget { + return [SettingsSearchResult(target: target, score: 0)] + } + return self.availableSettingsSearchResults(for: self.settingsSearchQuery) } private var filteredSettingsSections: [SettingsSection] { @@ -1439,6 +1535,7 @@ struct ContentView: View { private func updateSettingsSearchQuery(_ query: String) { self.settingsSearchQuery = query + self.settingsRevealTarget = nil self.settingsSearchScrollRequest += 1 guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } @@ -1450,24 +1547,16 @@ struct ContentView: View { } private func availableSettingsSearchResults(for query: String) -> [SettingsSearchResult] { - SettingsSearchIndex.results(for: query) - .filter { self.isSettingsSearchTargetAvailable($0.target) } - } - - private func isSettingsSearchTargetAvailable(_ target: SettingsSearchTarget) -> Bool { - switch target { - case .microphonePermission: - return self.asr.micStatus != .authorized - case .accessibilityPermission: - return !self.accessibilityEnabled - case .audioStorage: - return SettingsStore.shared.saveTranscriptionHistory && - SettingsStore.shared.saveAudioWithTranscriptionHistory - case .bottomOffset: - return self.settings.overlayPosition == .bottom - default: - return true - } + SettingsSearchIndex.results( + for: query, + availability: SettingsSearchAvailability( + microphoneAuthorized: self.asr.micStatus == .authorized, + accessibilityEnabled: self.accessibilityEnabled, + savesTranscriptionHistory: SettingsStore.shared.saveTranscriptionHistory, + savesAudioWithTranscriptionHistory: SettingsStore.shared.saveAudioWithTranscriptionHistory, + overlayAtBottom: self.settings.overlayPosition == .bottom + ) + ) } private var settingsEntryButton: some View { @@ -1645,12 +1734,13 @@ struct ContentView: View { theme: self.theme, selectedConfigurationSection: self.aiEnhancementConfigurationSectionBinding, activeShortcutRecordingTarget: self.$activeShortcutRecordingTarget, - shortcutRecordingMessage: self.$shortcutRecordingMessage + shortcutRecordingMessage: self.$shortcutRecordingMessage, + revealTarget: self.$appSearchRevealTarget )) case .meetingTools: return AnyView(self.meetingToolsView) case .customDictionary: - return AnyView(CustomDictionaryView()) + return AnyView(CustomDictionaryView(revealTarget: self.$appSearchRevealTarget)) case .stats: return AnyView(self.statsView) case .feedback: @@ -2889,6 +2979,19 @@ struct ContentView: View { source: "ContentView" ) + // A sidebar search field that was already focused when recording began gets + // the text through its field editor, which also fires change notifications. + if isFluidFrontmost, shouldPersistOutputs, !sendsExistingDraft, + let focusTarget = self.recordingFocusTarget, + focusTarget.pid == ProcessInfo.processInfo.processIdentifier, + TypingService.isExactFocusTargetActive(focusTarget), + let window = NSApp.keyWindow, + let editor = window.firstResponder as? NSTextView, + SidebarSearchField.owns(editor, in: window) + { + editor.insertText(finalText, replacementRange: editor.selectedRange()) + } + if shouldTypeExternally { let typingTarget = self.resolveTypingTargetPID() let spokenSendRequested = spokenSendParse.shouldSend @@ -5198,7 +5301,7 @@ private struct SidebarChromeButtonStyle: ButtonStyle { } } -private extension View { +extension View { func sidebarOptionHover(isSelected: Bool, reduceMotion: Bool) -> some View { modifier(SidebarOptionHoverModifier(isSelected: isSelected, reduceMotion: reduceMotion)) } diff --git a/Sources/Fluid/Persistence/ChatHistoryStore.swift b/Sources/Fluid/Persistence/ChatHistoryStore.swift index fd73c9cf4..7de74ec01 100644 --- a/Sources/Fluid/Persistence/ChatHistoryStore.swift +++ b/Sources/Fluid/Persistence/ChatHistoryStore.swift @@ -58,16 +58,32 @@ struct ChatSession: Codable, Identifiable, Equatable { var title: String let createdAt: Date var updatedAt: Date + var searchRevision: UInt64? var messages: [ChatMessage] - init(id: String = UUID().uuidString, title: String = "New Chat", createdAt: Date = Date(), updatedAt: Date = Date(), messages: [ChatMessage] = []) { + init( + id: String = UUID().uuidString, + title: String = "New Chat", + createdAt: Date = Date(), + updatedAt: Date = Date(), + searchRevision: UInt64? = nil, + messages: [ChatMessage] = [] + ) { self.id = id self.title = title self.createdAt = createdAt self.updatedAt = updatedAt + self.searchRevision = searchRevision self.messages = messages } + mutating func markUpdated(at date: Date = Date()) { + let timestampRevision = UInt64(max(1, self.updatedAt.timeIntervalSince1970 * 1000)) + let previousRevision = max(self.searchRevision ?? timestampRevision, timestampRevision) + self.searchRevision = previousRevision == .max ? .max : previousRevision + 1 + self.updatedAt = date + } + /// Generate title from first user message (max 50 chars) mutating func updateTitleFromFirstMessage() { guard let firstUserMessage = messages.first(where: { $0.role == .user }) else { return } @@ -156,11 +172,11 @@ final class ChatHistoryStore: ObservableObject { func saveChat(_ session: ChatSession) { if let index = sessions.firstIndex(where: { $0.id == session.id }) { var updated = session - updated.updatedAt = Date() + updated.markUpdated() self.sessions[index] = updated } else { var updated = session - updated.updatedAt = Date() + updated.markUpdated() self.sessions.insert(updated, at: 0) } @@ -175,7 +191,7 @@ final class ChatHistoryStore: ObservableObject { var session = self.sessions[index] session.messages = messages - session.updatedAt = Date() + session.markUpdated() session.updateTitleFromFirstMessage() self.sessions[index] = session @@ -226,7 +242,7 @@ final class ChatHistoryStore: ObservableObject { self.sessions[index].messages = [] self.sessions[index].title = "New Chat" - self.sessions[index].updatedAt = Date() + self.sessions[index].markUpdated() self.saveSessions() } diff --git a/Sources/Fluid/Persistence/Search/SearchIndex.swift b/Sources/Fluid/Persistence/Search/SearchIndex.swift new file mode 100644 index 000000000..81292c579 --- /dev/null +++ b/Sources/Fluid/Persistence/Search/SearchIndex.swift @@ -0,0 +1,129 @@ +import Foundation +import ZeppelinEmbed + +/// The full-text index behind the sidebar search: one record-only Zeppelin +/// namespace per `SearchIndexKind`. +/// +/// This is a derived copy. The stores in `UserDefaults` remain the source of truth, +/// so a namespace that is missing, reset, or from another build is simply rebuilt +/// from its store. +/// +/// The one write operation is `reconcile`: compare what the store has with what the +/// namespace has, delete the rest, upsert the difference. It is idempotent, so the +/// same call is the launch backfill, the write-through after a change, the cleanup +/// after a store evicts rows past its cap, and the recovery after a reset. An +/// interrupted run leaves nothing to undo; the next run finishes it. +actor SearchIndex { + static let shared = SearchIndex() + + struct ReconcileReport: Equatable, Sendable { + var upserted = 0 + var deleted = 0 + } + + struct Hit: Equatable, Sendable { + let id: UUID + let score: Double + } + + private let root: FluidZeppelinRoot + + init(root: FluidZeppelinRoot = .shared) { + self.root = root + } + + /// No attributes: there is no filtered lexical query, and grouping is by + /// namespace. No vector space: this index is words only. + static let spec = NamespaceSpec(attributes: [], vectorSpace: nil) + + func namespace(_ kind: SearchIndexKind) async throws -> ZeppelinStore { + try await self.root.namespace(kind.rawValue, spec: Self.spec) + } + + // MARK: - Reconcile + + /// Makes the namespace equal to `records`. + @discardableResult + func reconcile( + _ kind: SearchIndexKind, + with records: [SearchIndexRecord] + ) async throws -> ReconcileReport { + let store = try await self.namespace(kind) + var report = ReconcileReport() + + var indexed: [UUID: UInt64] = [:] + for try await document in store.documents(fields: []) { + indexed[document.id.uuid] = document.revision + } + + var wanted = Set() + var pending: [SearchIndexRecord] = [] + for record in records { + wanted.insert(record.id) + guard let revision = indexed[record.id] else { + pending.append(record) + continue + } + // Equal: already indexed. Higher: a newer snapshot has already won, + // so reconciliation must not replace it with stale input. + if revision < record.revision { + pending.append(record) + } + } + let stale = indexed.keys + .filter { !wanted.contains($0) } + .map { DocumentID(uuid: $0) } + + if !stale.isEmpty { + _ = try await store.delete(stale) + report.deleted = stale.count + } + if !pending.isEmpty { + _ = try await store.upsert(pending.map(Self.document)) + report.upserted = pending.count + } + if report != ReconcileReport() { + await DebugLogger.shared.info( + "Search index \(kind.rawValue): +\(report.upserted) -\(report.deleted)", + source: "SearchIndex" + ) + } + return report + } + + private static func document(_ record: SearchIndexRecord) -> IngestDocument { + IngestDocument( + id: DocumentID(uuid: record.id), + revision: record.revision, + timestamp: Int64(record.timestamp.timeIntervalSince1970 * 1000), + vector: [], + text: record.text + ) + } + + // MARK: - Query + + /// Ranked ids for `text`. The last word is matched as a prefix so results appear + /// while the user types. Scores are only comparable within one kind. + func query( + _ kind: SearchIndexKind, + text: String, + limit: Int, + cancellationToken: ZeppelinCancellationToken? = nil + ) async throws -> [Hit] { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, limit > 0 else { return [] } + let store = try await self.namespace(kind) + let result = try await store.query( + text: trimmed, + options: QueryOptions( + k: limit, + lastAsPrefix: true, + cancellationToken: cancellationToken + ) + ) + return result.hits.compactMap { hit in + hit.documentID.map { Hit(id: $0.uuid, score: hit.score) } + } + } +} diff --git a/Sources/Fluid/Persistence/Search/SearchIndexCoordinator.swift b/Sources/Fluid/Persistence/Search/SearchIndexCoordinator.swift new file mode 100644 index 000000000..7dd4c32f8 --- /dev/null +++ b/Sources/Fluid/Persistence/Search/SearchIndexCoordinator.swift @@ -0,0 +1,68 @@ +import Combine +import Foundation + +/// Keeps `SearchIndex` equal to the three stores it mirrors. +/// +/// Each store publishes its whole array on every change and replays its current +/// snapshot on subscription. History waits until its asynchronous load succeeds, +/// so an incomplete snapshot cannot erase the index. One subscription per store +/// covers the launch backfill and every later change with the same code path. +/// The debounce folds a burst of saves into one reconcile, and reconciles for +/// one kind run in order so two cannot interleave. +@MainActor +final class SearchIndexCoordinator { + static let shared = SearchIndexCoordinator() + + private var cancellables: Set = [] + private var pending: [SearchIndexKind: Task] = [:] + private var seeded: Set = [] + private let index: SearchIndex + + init(index: SearchIndex = .shared) { + self.index = index + } + + func start(historyStore: TranscriptionHistoryStore = .shared) { + guard self.cancellables.isEmpty else { return } + // Results join the index against `TranscriptionHistoryStore.entries`, which is + // empty until the load finishes. A query typed before then finds nothing to + // join, and an index already equal to the loaded history reconciles to no + // change, so the first snapshot has to refresh on its own. + self.mirror( + .history, + historyStore.loadedEntriesPublisher.map { $0.map(\.searchRecord) }, + refreshOnFirstSnapshot: true + ) + self.mirror(.transcripts, FileTranscriptionHistoryStore.shared.$entries.map { $0.map(\.searchRecord) }) + self.mirror(.chats, ChatHistoryStore.shared.$sessions.map { $0.compactMap(\.searchRecord) }) + } + + private func mirror( + _ kind: SearchIndexKind, + _ records: P, + refreshOnFirstSnapshot: Bool = false + ) where P.Output == [SearchIndexRecord], P.Failure == Never { + records + .debounce(for: .milliseconds(250), scheduler: DispatchQueue.main) + .sink { [weak self] records in + guard let self else { return } + let isFirstSnapshot = self.seeded.insert(kind).inserted + let previous = self.pending[kind] + self.pending[kind] = Task { [index] in + await previous?.value + do { + let report = try await index.reconcile(kind, with: records) + if report != SearchIndex.ReconcileReport() || (refreshOnFirstSnapshot && isFirstSnapshot) { + AppSearchService.shared.refresh() + } + } catch { + DebugLogger.shared.error( + "Search index \(kind.rawValue) reconcile failed: \(error)", + source: "SearchIndex" + ) + } + } + } + .store(in: &self.cancellables) + } +} diff --git a/Sources/Fluid/Persistence/Search/SearchIndexRecord.swift b/Sources/Fluid/Persistence/Search/SearchIndexRecord.swift new file mode 100644 index 000000000..980988554 --- /dev/null +++ b/Sources/Fluid/Persistence/Search/SearchIndexRecord.swift @@ -0,0 +1,97 @@ +import Foundation + +/// The kinds of content the sidebar search indexes in Zeppelin. Each is one +/// record-only namespace, so results are grouped by kind rather than ranked across +/// kinds: BM25 scores from different namespaces are not comparable. +nonisolated enum SearchIndexKind: String, CaseIterable, Sendable { + case history = "search-history" + case transcripts = "search-transcripts" + case chats = "search-chats" +} + +/// One row as the index sees it: identity, a revision that rises when the text +/// changes, and the text itself. The stores build these; the index never reads a +/// store directly. +nonisolated struct SearchIndexRecord: Sendable, Equatable { + let id: UUID + /// Compared with what the namespace holds. A higher value re-indexes the row. + /// Rows whose text never changes use 1; chats use their update time, and history + /// entries the revision a restore bumped. + let revision: UInt64 + let timestamp: Date + let text: String + + init(id: UUID, revision: UInt64 = 1, timestamp: Date, text: String) { + self.id = id + self.revision = revision + self.timestamp = timestamp + self.text = text + } + + /// Joins the searchable fields of a row. Empty fields are dropped so they do not + /// add blank lines to the text. + static func joined(_ parts: [String]) -> String { + parts + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: "\n") + } +} + +extension TranscriptionHistoryEntry { + /// What was pasted, plus where. Raw text is left out: the user remembers what + /// landed in the document, and `processedText` is that text whether or not AI + /// cleanup ran. + /// + /// An entry is immutable, so its revision only moves when a restore replaces the + /// id with different text; everything else stays at the revision it was indexed at. + var searchRecord: SearchIndexRecord { + SearchIndexRecord( + id: self.id, + revision: self.searchRevision ?? 1, + timestamp: self.timestamp, + text: SearchIndexRecord.joined([self.processedText, self.appName, self.windowTitle]) + ) + } +} + +extension FileTranscriptionEntry { + /// `text` only. When diarization ran, `text` is already the speaker segments + /// joined together, so indexing both would count every word twice. + /// + /// Revision 1 is enough here: every transcription is stored under a fresh id, the + /// store only appends and deletes, and no backup restores these rows, so an id + /// never comes back with different text. + var searchRecord: SearchIndexRecord { + SearchIndexRecord( + id: self.id, + timestamp: self.timestamp, + text: SearchIndexRecord.joined([self.fileName, self.text]) + ) + } +} + +extension ChatSession { + /// One record per session. Message ids are regenerated every time a chat is + /// reloaded, so they cannot be keys; the session id is stable and the persisted + /// search revision rises on every save regardless of wall-clock corrections. + /// + /// Returns `nil` when the session id is not a UUID, which the store never + /// produces but a hand-edited defaults file could. + var searchRecord: SearchIndexRecord? { + guard let id = UUID(uuidString: self.id) else { return nil } + var parts = [self.title] + for message in self.messages { + parts.append(message.content) + if let command = message.toolCall?.command { + parts.append(command) + } + } + return SearchIndexRecord( + id: id, + revision: self.searchRevision ?? UInt64(max(1, self.updatedAt.timeIntervalSince1970 * 1000)), + timestamp: self.updatedAt, + text: SearchIndexRecord.joined(parts) + ) + } +} diff --git a/Sources/Fluid/Persistence/TranscriptionHistoryStore.swift b/Sources/Fluid/Persistence/TranscriptionHistoryStore.swift index c5a3524dc..e28cfcd23 100644 --- a/Sources/Fluid/Persistence/TranscriptionHistoryStore.swift +++ b/Sources/Fluid/Persistence/TranscriptionHistoryStore.swift @@ -38,6 +38,9 @@ struct TranscriptionHistoryEntry: Codable, Identifiable, Equatable, Sendable { /// message for display / debugging. let aiProcessingError: String? let audio: DictationAudioMetadata? + /// Rises when a restore gives this id different text. Nil on entries written + /// before the field existed, and on entries that were never restored. + var searchRevision: UInt64? init( id: UUID = UUID(), @@ -52,7 +55,8 @@ struct TranscriptionHistoryEntry: Codable, Identifiable, Equatable, Sendable { aiProcessingDurationMilliseconds: Int? = nil, aiTokensPerSecond: Double? = nil, aiProcessingError: String? = nil, - audio: DictationAudioMetadata? = nil + audio: DictationAudioMetadata? = nil, + searchRevision: UInt64? = nil ) { self.id = id self.timestamp = timestamp @@ -68,6 +72,7 @@ struct TranscriptionHistoryEntry: Codable, Identifiable, Equatable, Sendable { self.aiTokensPerSecond = aiTokensPerSecond self.aiProcessingError = aiProcessingError self.audio = audio + self.searchRevision = searchRevision } private init( @@ -84,7 +89,8 @@ struct TranscriptionHistoryEntry: Codable, Identifiable, Equatable, Sendable { aiProcessingDurationMilliseconds: Int?, aiTokensPerSecond: Double?, aiProcessingError: String?, - audio: DictationAudioMetadata? + audio: DictationAudioMetadata?, + searchRevision: UInt64? ) { self.id = id self.timestamp = timestamp @@ -100,6 +106,7 @@ struct TranscriptionHistoryEntry: Codable, Identifiable, Equatable, Sendable { self.aiTokensPerSecond = aiTokensPerSecond self.aiProcessingError = aiProcessingError self.audio = audio + self.searchRevision = searchRevision } init(from decoder: Decoder) throws { @@ -124,6 +131,7 @@ struct TranscriptionHistoryEntry: Codable, Identifiable, Equatable, Sendable { self.aiTokensPerSecond = try container.decodeIfPresent(Double.self, forKey: .aiTokensPerSecond) self.aiProcessingError = try container.decodeIfPresent(String.self, forKey: .aiProcessingError) self.audio = try container.decodeIfPresent(DictationAudioMetadata.self, forKey: .audio) + self.searchRevision = try container.decodeIfPresent(UInt64.self, forKey: .searchRevision) } private enum CodingKeys: String, CodingKey { @@ -131,7 +139,7 @@ struct TranscriptionHistoryEntry: Codable, Identifiable, Equatable, Sendable { case characterCount, wasAIProcessed, processingModel case transcriptionDurationMilliseconds, aiProcessingDurationMilliseconds case aiTokensPerSecond - case aiProcessingError, audio + case aiProcessingError, audio, searchRevision } /// Preview text for list display (first 80 chars) @@ -198,9 +206,20 @@ struct TranscriptionHistoryEntry: Codable, Identifiable, Equatable, Sendable { aiProcessingDurationMilliseconds: self.aiProcessingDurationMilliseconds, aiTokensPerSecond: self.aiTokensPerSecond, aiProcessingError: self.aiProcessingError, - audio: audio + audio: audio, + searchRevision: self.searchRevision ) } + + /// A restore can hand an existing id different text, and the search index only + /// replaces a row whose revision rose. Bumping past the restored revision, the + /// indexed one and the wall clock keeps that true whichever backup the text came + /// from, including one written before this field existed. + mutating func markRestored(over indexed: UInt64?, at date: Date = Date()) { + let now = UInt64(max(1, date.timeIntervalSince1970 * 1000)) + let base = max(now, self.searchRevision ?? 1, indexed ?? 1) + self.searchRevision = base == .max ? .max : base + 1 + } } // MARK: - Transcription History Store @@ -211,7 +230,7 @@ final class TranscriptionHistoryStore: ObservableObject { private let writer: TranscriptionHistoryWriter private var loadTask: Task? - private var hasLoaded = false + @Published private var hasLoaded = false private var pendingUpserts: [UUID: TranscriptionHistoryEntry] = [:] private var pendingDeletes: Set = [] private var pendingReplacement = false @@ -219,6 +238,15 @@ final class TranscriptionHistoryStore: ObservableObject { @Published private(set) var persistenceError: String? @Published private(set) var entries: [TranscriptionHistoryEntry] = [] + + /// Replays the current snapshot only after loading and merging pending edits succeed. + /// An empty loaded history is authoritative; an unloaded or failed one is not. + var loadedEntriesPublisher: AnyPublisher<[TranscriptionHistoryEntry], Never> { + self.$entries.combineLatest(self.$hasLoaded) + .compactMap { entries, hasLoaded in hasLoaded ? entries : nil } + .eraseToAnyPublisher() + } + @Published var selectedEntryID: UUID? /// Last completed snapshot while a coalesced background refresh is pending. /// Rendering must never scan history or schedule work. @@ -232,15 +260,18 @@ final class TranscriptionHistoryStore: ObservableObject { private var calendarObservers: [NSObjectProtocol] = [] private let summaryNow: () -> Date private let summaryCalendar: () -> Calendar + private let deleteAllAudioFiles: () -> Void init( writer: TranscriptionHistoryWriter = TranscriptionHistoryWriter(), summaryNow: @escaping () -> Date = Date.init, - summaryCalendar: @escaping () -> Calendar = { Calendar.current } + summaryCalendar: @escaping () -> Calendar = { Calendar.current }, + deleteAllAudioFiles: @escaping () -> Void = { DictationAudioHistoryStore.shared.deleteAllAudioFiles() } ) { self.writer = writer self.summaryNow = summaryNow self.summaryCalendar = summaryCalendar + self.deleteAllAudioFiles = deleteAllAudioFiles self.observeSummaryCalendarChanges() self.loadEntries() } @@ -355,7 +386,7 @@ final class TranscriptionHistoryStore: ObservableObject { func clearAllHistory() { self.audioSaveGeneration &+= 1 self.invalidateAutomaticAudioBudgetMeasurement() - DictationAudioHistoryStore.shared.deleteAllAudioFiles() + self.deleteAllAudioFiles() self.entries.removeAll() self.refreshTodaySummary() self.selectedEntryID = nil @@ -401,7 +432,14 @@ final class TranscriptionHistoryStore: ObservableObject { func restore(from payload: [TranscriptionHistoryEntry]) { self.audioSaveGeneration &+= 1 self.invalidateAutomaticAudioBudgetMeasurement() - self.entries = payload.sorted { $0.timestamp > $1.timestamp } + let indexed = Dictionary(self.entries.map { ($0.id, $0.searchRecord.revision) }) { first, _ in first } + self.entries = payload + .map { entry in + var entry = entry + entry.markRestored(over: indexed[entry.id]) + return entry + } + .sorted { $0.timestamp > $1.timestamp } self.refreshTodaySummary() self.selectedEntryID = self.entries.first?.id self.persist(upserts: self.entries, replacing: true) @@ -431,7 +469,7 @@ final class TranscriptionHistoryStore: ObservableObject { self.audioSaveGeneration &+= 1 self.invalidateAutomaticAudioBudgetMeasurement() let removedCount = self.entries.filter { $0.audio != nil }.count - DictationAudioHistoryStore.shared.deleteAllAudioFiles() + self.deleteAllAudioFiles() let changed = self.entries.filter { $0.audio != nil }.map { $0.replacingAudio(nil) } self.entries = self.entries.map { $0.replacingAudio(nil) } self.persist(upserts: changed) diff --git a/Sources/Fluid/Persistence/Zeppelin/FluidZeppelinRoot.swift b/Sources/Fluid/Persistence/Zeppelin/FluidZeppelinRoot.swift new file mode 100644 index 000000000..1aebc143a --- /dev/null +++ b/Sources/Fluid/Persistence/Zeppelin/FluidZeppelinRoot.swift @@ -0,0 +1,73 @@ +import Foundation +import ZeppelinEmbed + +/// The Zeppelin database at `~/Library/Application Support/FluidVoice/zeppelin/`. +/// +/// Each namespace is one directory under the root, created on demand and independent +/// of the others. Every namespace here is a derived copy of a store the app already +/// keeps, so the library's default `derived` durability applies and one that cannot +/// be read is reset and rebuilt from its source. +actor FluidZeppelinRoot { + static let shared = FluidZeppelinRoot() + + /// Errors that mean the bytes on disk cannot be interpreted. `.io` is absent: a + /// full disk or a lost volume is not a reason to throw the index away. + private static let unreadable: Set = [.corrupt, .epochMismatch, .schemaMismatch] + + private let root: URL + private var namespaces: [String: Task] = [:] + + init(root: URL? = nil) { + self.root = root ?? FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("FluidVoice/zeppelin", isDirectory: true) + } + + /// Opens a namespace, or returns the handle already open for it. + /// + /// A second open of the same directory fails on its writer lock, so the open is + /// cached as a task: concurrent first callers share one open instead of racing. + func namespace(_ name: String, spec: NamespaceSpec) async throws -> ZeppelinStore { + if let open = self.namespaces[name] { + return try await open.value + } + let open = Task { try await self.open(name, spec: spec) } + self.namespaces[name] = open + do { + return try await open.value + } catch { + self.namespaces[name] = nil + throw error + } + } + + private func open(_ name: String, spec: NamespaceSpec) async throws -> ZeppelinStore { + try FileManager.default.createDirectory(at: self.root, withIntermediateDirectories: true) + do { + return try await ZeppelinStore.openNamespace(root: self.root, name: name, spec: spec, options: OpenOptions()) + } catch let error as ZeppelinError where Self.unreadable.contains(error) { + await DebugLogger.shared.error( + "Zeppelin namespace \(name) could not be read (\(error)); resetting it", + source: "FluidZeppelinRoot" + ) + try FileManager.default.removeItem(at: self.root.appendingPathComponent(name, isDirectory: true)) + return try await ZeppelinStore.openNamespace(root: self.root, name: name, spec: spec, options: OpenOptions()) + } + } + + /// Closes every open namespace so their logs are checkpointed rather than + /// replayed on the next launch. Called from `applicationWillTerminate`. + func closeAll() async { + for (name, open) in self.namespaces { + do { + try await open.value.close() + } catch { + await DebugLogger.shared.warning( + "Zeppelin namespace \(name) did not close cleanly: \(error)", + source: "FluidZeppelinRoot" + ) + } + } + self.namespaces.removeAll() + } +} diff --git a/Sources/Fluid/Services/AppSearch/AppSearchService.swift b/Sources/Fluid/Services/AppSearch/AppSearchService.swift new file mode 100644 index 000000000..ed914e5f3 --- /dev/null +++ b/Sources/Fluid/Services/AppSearch/AppSearchService.swift @@ -0,0 +1,299 @@ +import Combine +import Foundation +import ZeppelinEmbed + +/// The sections a search result can belong to, in the order groups are shown. +nonisolated enum AppSearchKind: CaseIterable, Sendable { + case history, transcripts, chats, dictionary, prompts, vocabulary, punctuation, settings + + var title: String { + switch self { + case .history: "History" + case .transcripts: "Transcripts" + case .chats: "Chats" + case .dictionary: "Dictionary" + case .prompts: "Prompts" + case .vocabulary: "Vocabulary" + case .punctuation: "Punctuation" + case .settings: "Settings" + } + } +} + +nonisolated struct AppSearchHit: Identifiable, Equatable, Sendable { + /// What to open when the row is chosen. + enum Target: Hashable, Sendable { + case history(UUID) + case transcript(UUID) + case chat(String) + case dictionaryEntry(UUID) + case prompt(String) + case vocabulary(String) + case punctuation(UUID) + case settings(SettingsSearchTarget) + } + + let kind: AppSearchKind + let target: Target + let title: String + let snippet: AttributedString + let date: Date? + + var id: Target { + self.target + } +} + +nonisolated struct AppSearchGroup: Identifiable, Equatable, Sendable { + let kind: AppSearchKind + let hits: [AppSearchHit] + + var id: AppSearchKind { + self.kind + } +} + +/// Turns the sidebar query into grouped results. +/// +/// The big three kinds are asked of the Zeppelin index; the short lists are matched +/// in memory. Groups are the ranking: a BM25 score from one namespace means nothing +/// next to one from another, so hits are never merged across kinds. +@MainActor +final class AppSearchService: ObservableObject { + static let shared = AppSearchService() + + /// Hits fetched per indexed kind. The UI shows a few and offers the rest. + static let limit = 50 + static let debounce: Duration = .milliseconds(80) + + @Published var query = "" { + didSet { + if self.query != oldValue { + self.schedule() + } + } + } + + @Published private(set) var groups: [AppSearchGroup] = [] + + private let index: SearchIndex + private var task: Task? + private var token: ZeppelinCancellationToken? + + init(index: SearchIndex = .shared) { + self.index = index + } + + /// Re-runs the current query. Called when the index changes under it. + func refresh() { + self.schedule() + } + + private func schedule() { + self.task?.cancel() + if let token = self.token { + self.token = nil + Task { try? await token.cancel() } + } + let query = self.query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { + self.groups = [] + return + } + self.groups = [] + self.task = Task { [weak self] in + try? await Task.sleep(for: Self.debounce) + guard !Task.isCancelled, let self else { return } + let groups = await self.search(query) + // A newer keystroke cancelled this task while it was waiting on the + // index; its answer would overwrite a fresher one. + guard !Task.isCancelled else { return } + self.groups = groups + } + } + + func search(_ query: String) async -> [AppSearchGroup] { + let token = try? await ZeppelinCancellationToken.create() + self.token = token + + async let history = self.hits(.history, query, token) + async let transcripts = self.hits(.transcripts, query, token) + async let chats = self.hits(.chats, query, token) + let indexed = await [ + self.historyGroup(history, query), + self.transcriptGroup(transcripts, query), + self.chatGroup(chats, query), + ] + let inMemory = [ + self.dictionaryGroup(query), + self.promptGroup(query), + self.vocabularyGroup(query), + self.punctuationGroup(query), + self.settingsGroup(query), + ] + return (indexed + inMemory).filter { !$0.hits.isEmpty } + } + + private func hits( + _ kind: SearchIndexKind, + _ query: String, + _ token: ZeppelinCancellationToken? + ) async -> [SearchIndex.Hit] { + do { + return try await self.index.query(kind, text: query, limit: Self.limit, cancellationToken: token) + } catch { + if !Task.isCancelled { + DebugLogger.shared.warning("Search \(kind.rawValue) failed: \(error)", source: "AppSearch") + } + return [] + } + } + + // MARK: - Indexed kinds + + /// Score first, newest breaks ties. Rows the store no longer has are dropped. + nonisolated static func ranked( + _ hits: [SearchIndex.Hit], + rows: [UUID: Row], + date: (Row) -> Date, + hit: (Row) -> AppSearchHit + ) -> [AppSearchHit] { + hits + .compactMap { found in rows[found.id].map { (score: found.score, row: $0) } } + .sorted { lhs, rhs in + lhs.score == rhs.score ? date(lhs.row) > date(rhs.row) : lhs.score > rhs.score + } + .map { hit($0.row) } + } + + private func historyGroup(_ hits: [SearchIndex.Hit], _ query: String) -> AppSearchGroup { + let rows = Dictionary(TranscriptionHistoryStore.shared.entries.map { ($0.id, $0) }) { first, _ in first } + return AppSearchGroup(kind: .history, hits: Self.ranked(hits, rows: rows, date: \.timestamp) { entry in + AppSearchHit( + kind: .history, + target: .history(entry.id), + title: Self.firstLine(entry.processedText), + snippet: AppSearchSnippet.make(entry.searchRecord.text, query: query), + date: entry.timestamp + ) + }) + } + + private func transcriptGroup(_ hits: [SearchIndex.Hit], _ query: String) -> AppSearchGroup { + let rows = Dictionary(FileTranscriptionHistoryStore.shared.entries.map { ($0.id, $0) }) { first, _ in first } + return AppSearchGroup(kind: .transcripts, hits: Self.ranked(hits, rows: rows, date: \.timestamp) { entry in + AppSearchHit( + kind: .transcripts, + target: .transcript(entry.id), + title: entry.fileName, + snippet: AppSearchSnippet.make(entry.text, query: query), + date: entry.timestamp + ) + }) + } + + private func chatGroup(_ hits: [SearchIndex.Hit], _ query: String) -> AppSearchGroup { + let rows = Dictionary( + ChatHistoryStore.shared.sessions.compactMap { session in UUID(uuidString: session.id).map { ($0, session) } } + ) { first, _ in first } + return AppSearchGroup(kind: .chats, hits: Self.ranked(hits, rows: rows, date: \.updatedAt) { session in + AppSearchHit( + kind: .chats, + target: .chat(session.id), + title: session.title, + snippet: AppSearchSnippet.make(session.searchRecord?.text ?? session.title, query: query), + date: session.updatedAt + ) + }) + } + + // MARK: - In-memory kinds + + /// Every query word appears somewhere in the row, case-insensitively. The last + /// word matches by prefix here for free, because `contains` does. + nonisolated static func matches(_ query: String, _ fields: [String]) -> Bool { + let haystack = fields.joined(separator: "\n") + return AppSearchSnippet.words(in: query).allSatisfy { + haystack.range(of: $0, options: [.caseInsensitive, .diacriticInsensitive]) != nil + } + } + + private func dictionaryGroup(_ query: String) -> AppSearchGroup { + let hits = SettingsStore.shared.customDictionaryEntries + .filter { Self.matches(query, $0.triggers + [$0.replacement]) } + .map { entry in + AppSearchHit( + kind: .dictionary, + target: .dictionaryEntry(entry.id), + title: entry.replacement, + snippet: AppSearchSnippet.make(entry.triggers.joined(separator: ", "), query: query), + date: nil + ) + } + return AppSearchGroup(kind: .dictionary, hits: hits) + } + + private func promptGroup(_ query: String) -> AppSearchGroup { + let hits = SettingsStore.shared.dictationPromptProfiles + .filter { Self.matches(query, [$0.name, $0.prompt]) } + .map { profile in + AppSearchHit( + kind: .prompts, + target: .prompt(profile.id), + title: profile.name, + snippet: AppSearchSnippet.make(profile.prompt, query: query), + date: profile.updatedAt + ) + } + return AppSearchGroup(kind: .prompts, hits: hits) + } + + private func vocabularyGroup(_ query: String) -> AppSearchGroup { + let terms = (try? ParakeetVocabularyStore.shared.loadUserBoostTerms()) ?? [] + let hits = terms + .filter { Self.matches(query, [$0.text] + $0.aliases) } + .map { term in + AppSearchHit( + kind: .vocabulary, + target: .vocabulary(term.text), + title: term.text, + snippet: AppSearchSnippet.make(term.aliases.joined(separator: ", "), query: query), + date: nil + ) + } + return AppSearchGroup(kind: .vocabulary, hits: hits) + } + + private func punctuationGroup(_ query: String) -> AppSearchGroup { + let hits = SettingsStore.shared.punctuationDictionaryRules + .filter { Self.matches(query, $0.aliases + [$0.symbol]) } + .map { rule in + AppSearchHit( + kind: .punctuation, + target: .punctuation(rule.id), + title: rule.symbol, + snippet: AppSearchSnippet.make(rule.aliases.joined(separator: ", "), query: query), + date: nil + ) + } + return AppSearchGroup(kind: .punctuation, hits: hits) + } + + private func settingsGroup(_ query: String) -> AppSearchGroup { + let hits = SettingsSearchIndex.results(for: query, availability: .current).map { result in + AppSearchHit( + kind: .settings, + target: .settings(result.target), + title: SettingsSearchIndex.title(for: result.target), + snippet: AttributedString(result.section.title), + date: nil + ) + } + return AppSearchGroup(kind: .settings, hits: hits) + } + + private static func firstLine(_ text: String) -> String { + let line = text.split(whereSeparator: \.isNewline).first.map(String.init) ?? text + return line.count > 80 ? String(line.prefix(80)) + "…" : line + } +} diff --git a/Sources/Fluid/Services/AppSearch/AppSearchSnippet.swift b/Sources/Fluid/Services/AppSearch/AppSearchSnippet.swift new file mode 100644 index 000000000..914ed7043 --- /dev/null +++ b/Sources/Fluid/Services/AppSearch/AppSearchSnippet.swift @@ -0,0 +1,55 @@ +import Foundation + +/// A one-line excerpt of a result with the query words marked for bold. +/// +/// Zeppelin returns ids and scores, not match positions, so the words are found +/// again here: case-insensitively and by prefix, which also covers the stemmed +/// forms a prefix reaches (`meeting` marks `meetings`). Forms a prefix does not +/// reach (`ran` for `run`) are not marked; the row still shows. +nonisolated enum AppSearchSnippet { + /// Characters kept around the first match. + static let window = 110 + + static func words(in query: String) -> [String] { + query.lowercased() + .split(whereSeparator: \.isWhitespace) + .map(String.init) + } + + static func make(_ text: String, query: String) -> AttributedString { + let flat = text.split(whereSeparator: \.isNewline).joined(separator: " ") + let words = self.words(in: query) + let options: String.CompareOptions = [.caseInsensitive, .diacriticInsensitive] + + // Excerpt around the earliest match, or the start of the text. + let first = words + .compactMap { flat.range(of: $0, options: options)?.lowerBound } + .min() ?? flat.startIndex + var start = first + var leading = self.window / 4 + while start > flat.startIndex, leading > 0 { + start = flat.index(before: start) + leading -= 1 + } + let end = flat.index(start, offsetBy: self.window, limitedBy: flat.endIndex) ?? flat.endIndex + var excerpt = String(flat[start.. flat.startIndex { + excerpt = "…" + excerpt + } + if end < flat.endIndex { + excerpt += "…" + } + + var result = AttributedString(excerpt) + for word in words { + var searchFrom = excerpt.startIndex + while let range = excerpt.range(of: word, options: options, range: searchFrom.. = .constant(.providers), activeShortcutRecordingTarget: Binding = .constant(nil), - shortcutRecordingMessage: Binding = .constant(nil) + shortcutRecordingMessage: Binding = .constant(nil), + revealTarget: Binding = .constant(nil) ) { self.menuBarManager = menuBarManager self.theme = theme _selectedConfigurationSection = selectedConfigurationSection _activeShortcutRecordingTarget = activeShortcutRecordingTarget _shortcutRecordingMessage = shortcutRecordingMessage + _revealTarget = revealTarget let enhancementModel = AIEnhancementSettingsViewModel( settings: SettingsStore.shared, menuBarManager: menuBarManager, @@ -73,5 +78,12 @@ struct AIEnhancementSettingsScreen: View { } .padding(14) } + .task(id: self.revealTarget) { + guard case let .prompt(id) = self.revealTarget, + let profile = self.viewModel.settings.dictationPromptProfiles.first(where: { $0.id == id }) + else { return } + self.viewModel.openEditor(for: profile) + self.revealTarget = nil + } } } diff --git a/Sources/Fluid/UI/AppSearchResultsView.swift b/Sources/Fluid/UI/AppSearchResultsView.swift new file mode 100644 index 000000000..a06cc4682 --- /dev/null +++ b/Sources/Fluid/UI/AppSearchResultsView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +/// Grouped search results shown in place of the sidebar sections while the search +/// box has text. Each group shows a few rows and a "more" row that expands it. +struct AppSearchResultsView: View { + static let rowsPerGroup = 5 + + @ObservedObject var service: AppSearchService + @Binding var cursor: AppSearchHit.Target? + @Binding var expanded: Set + let open: (AppSearchHit) -> Void + + @Environment(\.theme) private var theme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// The rows on screen, in order, for keyboard movement. + static func visibleHits(_ groups: [AppSearchGroup], expanded: Set) -> [AppSearchHit] { + groups.flatMap { group in + expanded.contains(group.kind) ? group.hits : Array(group.hits.prefix(self.rowsPerGroup)) + } + } + + var body: some View { + ScrollViewReader { proxy in + List { + if self.service.groups.isEmpty { + Text("No results") + .font(self.theme.typography.sidebarItem) + .foregroundStyle(.secondary) + .padding(.horizontal, self.theme.metrics.spacing.md) + } + ForEach(self.service.groups) { group in + Section { + let shown = self.expanded.contains(group.kind) ? group.hits : Array(group.hits.prefix(Self.rowsPerGroup)) + ForEach(shown) { hit in + self.row(hit) + } + if group.hits.count > shown.count { + Button("\(group.hits.count - shown.count) more…") { + self.expanded.insert(group.kind) + } + .buttonStyle(.plain) + .font(self.theme.typography.sidebarItem) + .foregroundStyle(self.theme.palette.accent) + .padding(.vertical, self.theme.metrics.spacing.xs / 2) + } + } header: { + Text(group.kind.title) + .font(self.theme.typography.sidebarSection) + .foregroundStyle(.secondary) + } + } + } + .listStyle(.sidebar) + .accentColor(self.theme.palette.accent) + .onChange(of: self.cursor) { _, target in + target.map { proxy.scrollTo($0) } + } + } + } + + private func row(_ hit: AppSearchHit) -> some View { + let isSelected = self.cursor == hit.target + return Button { + self.open(hit) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(hit.title) + .font(self.theme.typography.sidebarItem) + .foregroundStyle(isSelected ? Color.white : Color.primary) + .lineLimit(1) + Text(hit.snippet) + .font(.caption) + .foregroundStyle(isSelected ? Color.white.opacity(0.85) : Color.secondary) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .padding(.vertical, self.theme.metrics.spacing.xs / 2) + } + .buttonStyle(.plain) + .onHover { + if $0 { + self.cursor = hit.target + } + } + .sidebarOptionHover(isSelected: isSelected, reduceMotion: self.reduceMotion) + .id(hit.id) + } +} diff --git a/Sources/Fluid/UI/CustomDictionaryView.swift b/Sources/Fluid/UI/CustomDictionaryView.swift index 434c62f4e..5be05aa2c 100644 --- a/Sources/Fluid/UI/CustomDictionaryView.swift +++ b/Sources/Fluid/UI/CustomDictionaryView.swift @@ -17,6 +17,10 @@ struct CustomDictionaryView: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @EnvironmentObject private var appServices: AppServices + /// A row picked in the sidebar search. Opening it is the reveal; the binding is + /// cleared so the same row can be picked again later. + @Binding var revealTarget: AppSearchHit.Target? + @State private var entries: [SettingsStore.CustomDictionaryEntry] = SettingsStore.shared.customDictionaryEntries @State private var boostTerms: [ParakeetVocabularyStore.VocabularyConfig.Term] = [] @State private var editingEntry: SettingsStore.CustomDictionaryEntry? @@ -267,6 +271,9 @@ struct CustomDictionaryView: View { .padding(self.theme.metrics.spacing.xl) } .dismissTextFocusOnBackgroundTap() + .task(id: self.revealTarget) { + self.revealSearchTarget() + } .overlay { if let confirmation = self.replacementConfirmation { ReplacementConfirmationToast(confirmation: confirmation) @@ -1747,6 +1754,29 @@ struct CustomDictionaryView: View { self.manualReplacement = "" } + /// Opens the editor for the row the sidebar search matched, so the screen lands on + /// that word or rule instead of the top of the dictionary. + private func revealSearchTarget() { + switch self.revealTarget { + case let .dictionaryEntry(id): + self.entries = SettingsStore.shared.customDictionaryEntries + self.editingEntry = self.entries.first { $0.id == id } + case let .vocabulary(text): + self.presentCustomWords() + if let index = self.boostTerms.firstIndex(where: { $0.text == text }) { + self.editBoostTerm(at: index) + } + case let .punctuation(id): + self.presentPunctuationDictionary() + if let rule = self.punctuationRules.first(where: { $0.id == id }) { + self.editPunctuationRule(rule) + } + default: + return + } + self.revealTarget = nil + } + private func presentYourDictionary() { self.entries = SettingsStore.shared.customDictionaryEntries self.isYourDictionaryPresented = true diff --git a/Sources/Fluid/UI/MeetingTranscriptionView.swift b/Sources/Fluid/UI/MeetingTranscriptionView.swift index caf3cb234..d559c553f 100644 --- a/Sources/Fluid/UI/MeetingTranscriptionView.swift +++ b/Sources/Fluid/UI/MeetingTranscriptionView.swift @@ -1,6 +1,14 @@ import SwiftUI import UniformTypeIdentifiers +enum MeetingTranscriptionScrollTarget: Hashable { + case detail(UUID) + + static func selectedDetail(_ id: UUID?) -> Self? { + id.map(detail) + } +} + struct MeetingTranscriptionView: View { let asrService: ASRService @StateObject private var transcriptionService: MeetingTranscriptionService @@ -62,6 +70,7 @@ struct MeetingTranscriptionView: View { .padding(.bottom, 8) // Main Content Area + ScrollViewReader { proxy in ScrollView { VStack(spacing: 24) { // File Selection Card @@ -96,6 +105,16 @@ struct MeetingTranscriptionView: View { } .padding(24) } + // A transcript chosen by search is scrolled to its expanded detail. + .onAppear { + MeetingTranscriptionScrollTarget.selectedDetail(self.fileHistoryStore.selectedEntryID) + .map { proxy.scrollTo($0) } + } + .onChange(of: self.fileHistoryStore.selectedEntryID) { _, id in + MeetingTranscriptionScrollTarget.selectedDetail(id) + .map { proxy.scrollTo($0) } + } + } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(self.theme.palette.windowBackground) @@ -457,10 +476,12 @@ struct MeetingTranscriptionView: View { ForEach(self.fileHistoryStore.entries) { entry in VStack(spacing: 0) { self.recentEntryRow(entry: entry) + .id(entry.id) if self.fileHistoryStore.selectedEntryID == entry.id { Divider() .padding(.horizontal, 12) self.historyDetailCard(entry: entry) + .id(MeetingTranscriptionScrollTarget.detail(entry.id)) .transition(.opacity) } } diff --git a/Sources/Fluid/UI/SettingsSearch.swift b/Sources/Fluid/UI/SettingsSearch.swift index 83767ca77..8bdf2dad7 100644 --- a/Sources/Fluid/UI/SettingsSearch.swift +++ b/Sources/Fluid/UI/SettingsSearch.swift @@ -6,6 +6,7 @@ // import AppKit +import AVFoundation import Foundation import SwiftUI @@ -150,6 +151,59 @@ struct SettingsSearchResult: Identifiable, Equatable { } } +struct SettingsSearchAvailability { + let microphoneAuthorized: Bool + let accessibilityEnabled: Bool + let savesTranscriptionHistory: Bool + let savesAudioWithTranscriptionHistory: Bool + let overlayAtBottom: Bool + + static var current: Self { + let settings = SettingsStore.shared + return Self( + microphoneAuthorized: AVCaptureDevice.authorizationStatus(for: .audio) == .authorized, + accessibilityEnabled: AXIsProcessTrusted(), + savesTranscriptionHistory: settings.saveTranscriptionHistory, + savesAudioWithTranscriptionHistory: settings.saveAudioWithTranscriptionHistory, + overlayAtBottom: settings.overlayPosition == .bottom + ) + } + + func includes(_ target: SettingsSearchTarget) -> Bool { + switch target { + case .microphonePermission: + return !self.microphoneAuthorized + case .accessibilityPermission: + return !self.accessibilityEnabled + case .primaryDictationShortcuts, + .commandModeShortcut, + .editModeShortcut, + .cancelRecordingShortcut, + .pasteLastTranscriptionShortcut, + .activationMode, + .copyToClipboard, + .textInsertionMode, + .spokenSend, + .transcriptionHistory, + .audioHistory, + .usageStreak, + .skipSilentRecordings, + .pauseMedia, + .dictionarySuggestions, + .analyticsPrivacy: + return self.accessibilityEnabled + case .audioStorage: + return self.accessibilityEnabled && + self.savesTranscriptionHistory && + self.savesAudioWithTranscriptionHistory + case .bottomOffset: + return self.overlayAtBottom + default: + return true + } + } +} + enum SettingsSearchIndex { private static let queryStopWords: Set = ["and", "for", "from", "in", "of", "or", "the", "to", "with"] @@ -389,6 +443,17 @@ enum SettingsSearchIndex { .map(\.result) } + static func results( + for query: String, + availability: SettingsSearchAvailability + ) -> [SettingsSearchResult] { + self.results(for: query).filter { availability.includes($0.target) } + } + + static func title(for target: SettingsSearchTarget) -> String { + self.entries.first { $0.target == target }?.title ?? target.section.title + } + static func matchingSections(for query: String) -> [SettingsSection] { let matchingSections = Set(self.results(for: query).map(\.section)) return SettingsSection.allCases.filter(matchingSections.contains) @@ -546,9 +611,17 @@ enum SettingsSearchIndex { } } -struct SettingsSearchField: NSViewRepresentable { +/// An `NSSearchField` for a sidebar. Esc clears it. Up, Down and Return are handed +/// to `onCommand` so a results list can be driven from the keyboard, and Cmd+F +/// (`Notification.Name.sidebarSearchFocusRequested`) focuses whichever instance +/// is active. +struct SidebarSearchField: NSViewRepresentable { + private static let identifier = NSUserInterfaceItemIdentifier("FluidVoice.SidebarSearchField") + @Binding var text: String + let placeholder: String let isActive: Bool + var onCommand: (Selector) -> Bool = { _ in false } func makeCoordinator() -> Coordinator { Coordinator(self) @@ -557,12 +630,14 @@ struct SettingsSearchField: NSViewRepresentable { func makeNSView(context: Context) -> NSSearchField { let searchField = NSSearchField() searchField.delegate = context.coordinator - searchField.placeholderString = "Search Settings" + searchField.placeholderString = self.placeholder searchField.sendsSearchStringImmediately = true searchField.sendsWholeSearchString = false searchField.controlSize = .regular searchField.focusRingType = .default - searchField.setAccessibilityLabel("Search Settings") + searchField.identifier = Self.identifier + searchField.setAccessibilityLabel(self.placeholder) + context.coordinator.observeFocusRequests(for: searchField) return searchField } @@ -583,13 +658,42 @@ struct SettingsSearchField: NSViewRepresentable { window.makeFirstResponder(nil) } + static func owns(_ editor: NSTextView, in window: NSWindow) -> Bool { + guard editor.isFieldEditor, let contentView = window.contentView else { return false } + var views = [contentView] + while let view = views.popLast() { + if let searchField = view as? NSSearchField, + searchField.identifier == Self.identifier, + searchField.currentEditor() === editor + { + return true + } + views.append(contentsOf: view.subviews) + } + return false + } + final class Coordinator: NSObject, NSSearchFieldDelegate { - var parent: SettingsSearchField + var parent: SidebarSearchField + private var focusObserver: NSObjectProtocol? - init(_ parent: SettingsSearchField) { + init(_ parent: SidebarSearchField) { self.parent = parent } + deinit { + self.focusObserver.map(NotificationCenter.default.removeObserver) + } + + func observeFocusRequests(for searchField: NSSearchField) { + self.focusObserver = NotificationCenter.default.addObserver( + forName: .sidebarSearchFocusRequested, object: nil, queue: .main + ) { [weak self, weak searchField] _ in + guard let self, self.parent.isActive, let searchField else { return } + searchField.window?.makeFirstResponder(searchField) + } + } + func controlTextDidChange(_ notification: Notification) { guard let searchField = notification.object as? NSSearchField else { return } self.parent.text = searchField.stringValue @@ -600,7 +704,9 @@ struct SettingsSearchField: NSViewRepresentable { textView: NSTextView, doCommandBy commandSelector: Selector ) -> Bool { - guard commandSelector == #selector(NSResponder.cancelOperation(_:)) else { return false } + guard commandSelector == #selector(NSResponder.cancelOperation(_:)) else { + return self.parent.onCommand(commandSelector) + } textView.string = "" control.stringValue = "" self.parent.text = "" @@ -608,3 +714,7 @@ struct SettingsSearchField: NSViewRepresentable { } } } + +extension Notification.Name { + static let sidebarSearchFocusRequested = Notification.Name("FluidVoice.sidebarSearchFocusRequested") +} diff --git a/Sources/Fluid/UI/TranscriptionHistoryView.swift b/Sources/Fluid/UI/TranscriptionHistoryView.swift index b8fc3e859..edb9c89d7 100644 --- a/Sources/Fluid/UI/TranscriptionHistoryView.swift +++ b/Sources/Fluid/UI/TranscriptionHistoryView.swift @@ -11,7 +11,6 @@ struct TranscriptionHistoryView: View { @State private var showClearConfirmation: Bool = false @State private var showReportConfirmation: Bool = false @State private var selectedReportEntry: TranscriptionHistoryEntry? - @State private var selectedEntryID: UUID? @State private var audioEntryID: UUID? @State private var copiedEntryID: UUID? @State private var copyFeedbackTask: Task? @@ -30,6 +29,13 @@ struct TranscriptionHistoryView: View { ) } + /// Lives in the store so the sidebar search can select a row before this view + /// exists, and the store's delete and clear paths keep it valid. + private var selectedEntryID: UUID? { + get { self.historyStore.selectedEntryID } + nonmutating set { self.historyStore.selectedEntryID = newValue } + } + private var filteredEntries: [TranscriptionHistoryEntry] { self.historyStore.search(query: self.searchQuery) } @@ -184,15 +190,34 @@ struct TranscriptionHistoryView: View { // MARK: - Entry List private var entryListView: some View { - ScrollView { - LazyVStack(spacing: 6) { - ForEach(self.filteredEntries) { entry in - self.entryRow(entry) + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 6) { + ForEach(self.filteredEntries) { entry in + self.entryRow(entry) + .id(entry.id) + } } + .padding(.horizontal, 8) + .padding(.vertical, 6) } - .padding(.horizontal, 8) - .padding(.vertical, 6) + .onAppear { self.reveal(self.selectedEntryID, with: proxy) } + .onChange(of: self.historyStore.selectedEntryID) { _, id in self.reveal(id, with: proxy) } + } + } + + /// Scrolls to a row chosen elsewhere (the sidebar search). A local filter that + /// hides it is cleared first, or the selection would fall back to the first row. + private func reveal(_ id: UUID?, with proxy: ScrollViewProxy) { + guard let id else { return } + if !self.filteredEntries.contains(where: { $0.id == id }) { + self.searchQuery = "" + DispatchQueue.main.async { + proxy.scrollTo(id) + } + return } + proxy.scrollTo(id) } private func entryRow(_ entry: TranscriptionHistoryEntry) -> some View { diff --git a/Sources/Fluid/fluidApp.swift b/Sources/Fluid/fluidApp.swift index 4d94cbfca..b7524409b 100644 --- a/Sources/Fluid/fluidApp.swift +++ b/Sources/Fluid/fluidApp.swift @@ -37,6 +37,12 @@ struct FluidApp: App { } .keyboardShortcut(",", modifiers: .command) } + CommandGroup(after: .textEditing) { + Button("Find") { + NotificationCenter.default.post(name: .sidebarSearchFocusRequested, object: nil) + } + .keyboardShortcut("f", modifiers: .command) + } } } } diff --git a/Tests/FluidDictationIntegrationTests/AppSearchServiceTests.swift b/Tests/FluidDictationIntegrationTests/AppSearchServiceTests.swift new file mode 100644 index 000000000..1ba13dd4d --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/AppSearchServiceTests.swift @@ -0,0 +1,109 @@ +@testable import FluidVoice_Debug +import Foundation +import XCTest + +/// The query side of the sidebar search. +@MainActor +final class AppSearchServiceTests: XCTestCase { + private struct Row { + let id: UUID + let date: Date + } + + // MARK: - Ranking + + func testHitsAreOrderedByScoreThenNewestAndUnknownRowsAreDropped() { + let old = Row(id: UUID(), date: Date(timeIntervalSince1970: 100)) + let new = Row(id: UUID(), date: Date(timeIntervalSince1970: 200)) + let best = Row(id: UUID(), date: Date(timeIntervalSince1970: 0)) + let rows = Dictionary(uniqueKeysWithValues: [old, new, best].map { ($0.id, $0) }) + let hits = [ + SearchIndex.Hit(id: old.id, score: 1), + SearchIndex.Hit(id: UUID(), score: 9), + SearchIndex.Hit(id: new.id, score: 1), + SearchIndex.Hit(id: best.id, score: 2), + ] + + let ordered = AppSearchService.ranked(hits, rows: rows, date: \.date) { row in + AppSearchHit(kind: .history, target: .history(row.id), title: "", snippet: "", date: row.date) + } + + XCTAssertEqual(ordered.map(\.target), [.history(best.id), .history(new.id), .history(old.id)]) + } + + // MARK: - Stale results + + /// Two keystrokes in quick succession: only the second one's answer may land. + func testANewerQueryReplacesOneStillDebouncing() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("AppSearchServiceTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let service = AppSearchService(index: SearchIndex(root: FluidZeppelinRoot(root: root))) + + service.query = "launch at startup" + service.query = "accent color" + try await Task.sleep(for: .milliseconds(600)) + + let settings = try XCTUnwrap(service.groups.first { $0.kind == .settings }) + XCTAssertTrue(settings.hits.contains { $0.target == .settings(.accentColor) }) + XCTAssertFalse(settings.hits.contains { $0.target == .settings(.launchAtStartup) }) + + service.query = " " + XCTAssertTrue(service.groups.isEmpty) + } + + func testChangingQueryImmediatelyInvalidatesPublishedResults() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("AppSearchServiceTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let service = AppSearchService(index: SearchIndex(root: FluidZeppelinRoot(root: root))) + + service.query = "launch at startup" + try await Task.sleep(for: .milliseconds(600)) + XCTAssertFalse(service.groups.isEmpty) + + service.query = "accent color" + + XCTAssertTrue(service.groups.isEmpty) + } + + func testTranscriptSearchSelectionTargetsItsDetailCard() { + let id = UUID() + + XCTAssertEqual(MeetingTranscriptionScrollTarget.selectedDetail(id), .detail(id)) + XCTAssertNil(MeetingTranscriptionScrollTarget.selectedDetail(nil)) + } + + // MARK: - Snippets + + func testSnippetMarksEveryQueryWordIncludingByPrefix() { + let snippet = AppSearchSnippet.make("Two meetings today.\nOne Meeting tomorrow.", query: "meeting") + let marked = snippet.runs + .filter { $0.inlinePresentationIntent == .stronglyEmphasized } + .map { String(snippet[$0.range].characters) } + + XCTAssertEqual(String(snippet.characters), "Two meetings today. One Meeting tomorrow.") + XCTAssertEqual(marked, ["meeting", "Meeting"]) + } + + func testSnippetWindowsAroundTheFirstMatchWithEllipses() { + let filler = String(repeating: "word ", count: 60) + let snippet = String(AppSearchSnippet.make(filler + "harbour lights " + filler, query: "harbour").characters) + + XCTAssertTrue(snippet.hasPrefix("…")) + XCTAssertTrue(snippet.hasSuffix("…")) + XCTAssertTrue(snippet.contains("harbour lights")) + XCTAssertLessThan(snippet.count, AppSearchSnippet.window + 4) + } + + func testSnippetWithNoMatchStartsAtTheBeginning() { + let snippet = String(AppSearchSnippet.make("short text", query: "zzz").characters) + XCTAssertEqual(snippet, "short text") + } + + func testInMemoryMatchRequiresEveryWord() { + XCTAssertTrue(AppSearchService.matches("acc col", ["Accent Color"])) + XCTAssertFalse(AppSearchService.matches("accent blue", ["Accent Color"])) + XCTAssertTrue(AppSearchService.matches("cafe", ["café"])) + } +} diff --git a/Tests/FluidDictationIntegrationTests/SearchIndexTests.swift b/Tests/FluidDictationIntegrationTests/SearchIndexTests.swift new file mode 100644 index 000000000..899dcca56 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/SearchIndexTests.swift @@ -0,0 +1,411 @@ +@testable import FluidVoice_Debug +import Foundation +import SQLite3 +import XCTest +import ZeppelinEmbed + +/// The sidebar search index. +/// +/// `reconcile` is the only write path, so these tests pin the one property it must +/// have: after any call, the namespace equals the records handed in, whatever was +/// there before. +final class SearchIndexTests: XCTestCase { + private var root: URL! + private var index: SearchIndex! + + override func setUpWithError() throws { + self.root = FileManager.default.temporaryDirectory + .appendingPathComponent("SearchIndexTests-\(UUID().uuidString)", isDirectory: true) + self.index = SearchIndex(root: FluidZeppelinRoot(root: self.root)) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: self.root) + self.root = nil + self.index = nil + } + + private static func record(_ text: String, revision: UInt64 = 1) -> SearchIndexRecord { + SearchIndexRecord(id: UUID(), revision: revision, timestamp: Date(), text: text) + } + + private func ids(_ kind: SearchIndexKind, query: String) async throws -> Set { + Set(try await self.index.query(kind, text: query, limit: 50).map(\.id)) + } + + private func count(_ kind: SearchIndexKind) async throws -> Int { + Int(try await self.index.namespace(kind).count().count) + } + + // MARK: - Reconcile + + func testFirstReconcileIndexesEveryRowAndTheSecondWritesNothing() async throws { + let records = (1...3).map { Self.record("meeting number \($0)") } + + let first = try await self.index.reconcile(.history, with: records) + XCTAssertEqual(first, .init(upserted: 3, deleted: 0)) + let count = try await self.count(.history) + XCTAssertEqual(count, 3) + + let second = try await self.index.reconcile(.history, with: records) + XCTAssertEqual(second, .init(upserted: 0, deleted: 0)) + } + + /// Covers delete, clear, and the silent evictions at the transcript and chat caps: + /// the index only ever sees "these are the rows now". + func testRowsMissingFromTheStoreAreDeleted() async throws { + let records = (1...4).map { Self.record("harbour lights \($0)") } + try await self.index.reconcile(.transcripts, with: records) + + let kept = Array(records.prefix(2)) + let report = try await self.index.reconcile(.transcripts, with: kept) + XCTAssertEqual(report, .init(upserted: 0, deleted: 2)) + let found = try await self.ids(.transcripts, query: "harbour") + XCTAssertEqual(found, Set(kept.map(\.id))) + + let cleared = try await self.index.reconcile(.transcripts, with: []) + XCTAssertEqual(cleared, .init(upserted: 0, deleted: 2)) + let count = try await self.count(.transcripts) + XCTAssertEqual(count, 0) + } + + func testAHigherRevisionReplacesTheTextAndAnEqualOneIsLeftAlone() async throws { + let original = Self.record("first draft", revision: 10) + try await self.index.reconcile(.chats, with: [original]) + + let same = try await self.index.reconcile(.chats, with: [original]) + XCTAssertEqual(same.upserted, 0) + + let edited = SearchIndexRecord( + id: original.id, revision: 11, timestamp: original.timestamp, text: "second draft" + ) + let report = try await self.index.reconcile(.chats, with: [edited]) + XCTAssertEqual(report.upserted, 1) + let old = try await self.ids(.chats, query: "first") + XCTAssertTrue(old.isEmpty) + let new = try await self.ids(.chats, query: "second") + XCTAssertEqual(new, [original.id]) + } + + /// A damaged namespace is moved aside by `FluidZeppelinRoot`; the next reconcile + /// then refills the empty one. Nothing is lost because the store still has it. + func testAResetNamespaceIsRefilledByTheNextReconcile() async throws { + let records = (1...3).map { Self.record("recoverable \($0)") } + try await self.index.reconcile(.history, with: records) + try await self.index.namespace(.history).close() + + let manifest = self.root + .appendingPathComponent(SearchIndexKind.history.rawValue, isDirectory: true) + .appendingPathComponent("manifest.ze") + let original = try Data(contentsOf: manifest) + try original.prefix(original.count / 2).write(to: manifest) + + // A fresh root, as after a relaunch: the cached handle above is gone. + self.index = SearchIndex(root: FluidZeppelinRoot(root: self.root)) + let report = try await self.index.reconcile(.history, with: records) + XCTAssertEqual(report, .init(upserted: 3, deleted: 0)) + let found = try await self.ids(.history, query: "recoverable") + XCTAssertEqual(found, Set(records.map(\.id))) + } + + // MARK: - Query + + /// The reason for zeppelin-embed 0.3.0: the last word is a prefix, and thanks to + /// its two-way expansion the mid-word states of a stemmed word still match. + func testTheLastWordMatchesAsAPrefixWhileTyping() async throws { + let meeting = Self.record("meeting notes from monday") + let other = Self.record("the harbour lights at dusk") + try await self.index.reconcile(.history, with: [meeting, other]) + + for typed in ["mee", "meet", "meeti", "meetin", "meeting", "notes mo"] { + let found = try await self.ids(.history, query: typed) + XCTAssertEqual(found, [meeting.id], "\"\(typed)\" should find the meeting entry") + } + let blank = try await self.index.query(.history, text: " ", limit: 10) + XCTAssertTrue(blank.isEmpty) + } + + // MARK: - Records + + func testHistoryRecordHoldsThePastedTextNotTheRawTranscript() { + let entry = TranscriptionHistoryEntry( + rawText: "um the raw words", + processedText: "The clean words.", + appName: "Notes", + windowTitle: "Ideas", + wasAIProcessed: true + ) + XCTAssertEqual(entry.searchRecord.text, "The clean words.\nNotes\nIdeas") + XCTAssertEqual(entry.searchRecord.id, entry.id) + } + + /// `text` is already the speaker segments joined, so they must not be indexed + /// again on top of it. + func testTranscriptRecordDoesNotDuplicateSpeakerSegments() { + let entry = FileTranscriptionEntry( + fileName: "standup.m4a", + duration: 1, + processingTime: 1, + confidence: 1, + text: "[00:00] Alice: hello there\n\n[00:05] Bob: hello back", + speakerSegments: [ + SpeakerTranscriptSegment(speaker: "Alice", startSeconds: 0, endSeconds: 5, text: "hello there"), + SpeakerTranscriptSegment(speaker: "Bob", startSeconds: 5, endSeconds: 9, text: "hello back"), + ] + ) + let text = entry.searchRecord.text + XCTAssertEqual(text.components(separatedBy: "hello").count - 1, 2) + XCTAssertTrue(text.hasPrefix("standup.m4a\n")) + } + + func testChatRecordUsesTheUpdateTimeAsRevisionAndIncludesCommands() throws { + let updated = Date(timeIntervalSince1970: 1_700_000_000.5) + let session = ChatSession( + title: "Disk space", + updatedAt: updated, + messages: [ + ChatMessage(role: .user, content: "how full is the disk"), + ChatMessage( + role: .tool, + content: "", + toolCall: .init(id: "1", command: "df -h", workingDirectory: nil, purpose: nil) + ), + ] + ) + let record = try XCTUnwrap(session.searchRecord) + XCTAssertEqual(record.revision, 1_700_000_000_500) + XCTAssertEqual(record.text, "Disk space\nhow full is the disk\ndf -h") + XCTAssertNil(ChatSession(id: "not-a-uuid").searchRecord) + } +} + +/// Exercises startup through the real history writer, coordinator and Zeppelin queries. +@MainActor +final class SearchIndexCoordinatorTests: XCTestCase { + private struct Fixture { + let root: URL + let defaults: UserDefaults + let writer: TranscriptionHistoryWriter + let index: SearchIndex + + var historyURL: URL { + self.root.appendingPathComponent("history.sqlite3") + } + } + + private func withFixture(_ body: (Fixture) async throws -> Void) async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("SearchIndexCoordinatorTests-\(UUID().uuidString)", isDirectory: true) + let suite = "SearchIndexCoordinatorTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { + defaults.removePersistentDomain(forName: suite) + try? FileManager.default.removeItem(at: root) + } + let writer = TranscriptionHistoryWriter(defaults: defaults, url: root.appendingPathComponent("history.sqlite3")) + let fixture = Fixture( + root: root, + defaults: defaults, + writer: writer, + index: SearchIndex(root: FluidZeppelinRoot(root: root.appendingPathComponent("search"))) + ) + do { + try await body(fixture) + } catch { + _ = await writer.drain() + throw error + } + _ = await writer.drain() + } + + private func entry(_ text: String) -> TranscriptionHistoryEntry { + TranscriptionHistoryEntry( + rawText: text, processedText: text, appName: "Test", windowTitle: "Test", wasAIProcessed: false + ) + } + + private func indexedIDs(_ index: SearchIndex) async throws -> Set { + // Check emptiness directly before exercising lexical lookup. + let recordCount = try await index.namespace(.history).count().count + guard recordCount > 0 else { return [] } + return try Set(await index.query(.history, text: "startup", limit: 50).map(\.id)) + } + + private func waitForIndexedIDs(_ expected: Set, in index: SearchIndex) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + var actual = try await self.indexedIDs(index) + while actual != expected, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(25)) + actual = try await self.indexedIDs(index) + } + XCTAssertEqual(actual, expected) + } + + func testSlowStartupKeepsExistingIndexUntilHistoryLoads() async throws { + try await self.withFixture { fixture in + let saved = self.entry("startup saved dictation") + let stale = self.entry("startup stale index record") + try fixture.defaults.set(JSONEncoder().encode([saved]), forKey: "TranscriptionHistoryEntries") + try await fixture.index.reconcile(.history, with: [saved.searchRecord, stale.searchRecord]) + + // The initial migration needs a write lock. Hold it longer than the 250 ms debounce. + _ = try TranscriptionHistoryDatabase(url: fixture.historyURL) + var connection: OpaquePointer? + XCTAssertEqual(sqlite3_open(fixture.historyURL.path, &connection), SQLITE_OK) + let database = try XCTUnwrap(connection) + defer { sqlite3_close(database) } + XCTAssertEqual(sqlite3_exec(database, "BEGIN IMMEDIATE", nil, nil, nil), SQLITE_OK) + defer { sqlite3_exec(database, "ROLLBACK", nil, nil, nil) } + + let history = TranscriptionHistoryStore(writer: fixture.writer) + let coordinator = SearchIndexCoordinator(index: fixture.index) + defer { withExtendedLifetime(coordinator) {} } + coordinator.start(historyStore: history) + + try await Task.sleep(for: .milliseconds(700)) + XCTAssertTrue(history.isLoading, "The fixture must keep loading beyond the debounce") + let duringLoad = try await self.indexedIDs(fixture.index) + XCTAssertEqual(duringLoad, [saved.id, stale.id], "An incomplete startup snapshot must not delete indexed history") + + XCTAssertEqual(sqlite3_exec(database, "COMMIT", nil, nil, nil), SQLITE_OK) + try await history.waitUntilLoaded() + try await self.waitForIndexedIDs([saved.id], in: fixture.index) + } + } + + func testFailedLoadAndEditsPreserveIndexUntilRetryMergesCompleteHistory() async throws { + try await self.withFixture { fixture in + let saved = self.entry("startup saved dictation") + let deleted = self.entry("startup deleted while unavailable") + let stale = self.entry("startup stale index record") + let pending = self.entry("startup new dictation") + fixture.defaults.set(Data("invalid JSON".utf8), forKey: "TranscriptionHistoryEntries") + try await fixture.index.reconcile(.history, with: [saved, deleted, stale].map(\.searchRecord)) + + let history = TranscriptionHistoryStore(writer: fixture.writer) + let coordinator = SearchIndexCoordinator(index: fixture.index) + defer { withExtendedLifetime(coordinator) {} } + coordinator.start(historyStore: history) + do { + try await history.waitUntilLoaded() + XCTFail("The corrupt history fixture must fail to load") + } catch {} + XCTAssertFalse(history.isLoading) + XCTAssertNotNil(history.persistenceError) + + try await Task.sleep(for: .milliseconds(700)) + let afterFailure = try await self.indexedIDs(fixture.index) + XCTAssertEqual(afterFailure, [saved.id, deleted.id, stale.id]) + + history.addEntry( + id: pending.id, + timestamp: pending.timestamp, + rawText: pending.rawText, + processedText: pending.processedText, + appName: pending.appName, + windowTitle: pending.windowTitle + ) + history.deleteEntry(id: deleted.id) + try await Task.sleep(for: .milliseconds(700)) + let afterEdits = try await self.indexedIDs(fixture.index) + XCTAssertEqual(afterEdits, [saved.id, deleted.id, stale.id], "Edits after a failed load are still an incomplete snapshot") + + try fixture.defaults.set(JSONEncoder().encode([saved, deleted]), forKey: "TranscriptionHistoryEntries") + history.retryPersistence() + try await history.waitUntilLoaded() + XCTAssertNil(history.persistenceError) + XCTAssertEqual(Set(history.entries.map(\.id)), [saved.id, pending.id]) + try await self.waitForIndexedIDs([saved.id, pending.id], in: fixture.index) + await history.finishPendingWrites() + } + } + + func testSuccessfulEmptyLoadDeletesStaleIndexRecords() async throws { + try await self.withFixture { fixture in + let stale = self.entry("startup stale index record") + try await fixture.index.reconcile(.history, with: [stale.searchRecord]) + let history = TranscriptionHistoryStore(writer: fixture.writer) + let coordinator = SearchIndexCoordinator(index: fixture.index) + defer { withExtendedLifetime(coordinator) {} } + coordinator.start(historyStore: history) + + try await history.waitUntilLoaded() + XCTAssertTrue(history.entries.isEmpty) + try await self.waitForIndexedIDs([], in: fixture.index) + } + } + + func testClearingLoadedHistoryDeletesIndexedRecords() async throws { + try await self.withFixture { fixture in + let saved = self.entry("startup saved dictation") + try fixture.defaults.set(JSONEncoder().encode([saved]), forKey: "TranscriptionHistoryEntries") + try await fixture.index.reconcile(.history, with: [saved.searchRecord]) + // The isolated history has no audio; never clear the user's shared audio directory. + let history = TranscriptionHistoryStore(writer: fixture.writer, deleteAllAudioFiles: {}) + let coordinator = SearchIndexCoordinator(index: fixture.index) + defer { withExtendedLifetime(coordinator) {} } + coordinator.start(historyStore: history) + + try await history.waitUntilLoaded() + try await Task.sleep(for: .milliseconds(700)) + let beforeClear = try await self.indexedIDs(fixture.index) + XCTAssertEqual(beforeClear, [saved.id]) + history.clearAllHistory() + try await self.waitForIndexedIDs([], in: fixture.index) + await history.finishPendingWrites() + } + } + + /// A restore can put different text under an id the index already holds. The + /// restored entry therefore has to carry a higher revision than the indexed one, + /// or reconcile keeps serving the text from before the restore. + func testRestoringDifferentTextUnderAnIndexedEntryReplacesIt() async throws { + try await self.withFixture { fixture in + let saved = self.entry("startup saved dictation") + try fixture.defaults.set(JSONEncoder().encode([saved]), forKey: "TranscriptionHistoryEntries") + try await fixture.index.reconcile(.history, with: [saved.searchRecord]) + let history = TranscriptionHistoryStore(writer: fixture.writer, deleteAllAudioFiles: {}) + let coordinator = SearchIndexCoordinator(index: fixture.index) + defer { withExtendedLifetime(coordinator) {} } + coordinator.start(historyStore: history) + try await history.waitUntilLoaded() + + history.restore(from: [TranscriptionHistoryEntry( + id: saved.id, + timestamp: saved.timestamp, + rawText: "startup restored dictation", + processedText: "startup restored dictation", + appName: "Test", + windowTitle: "Test", + wasAIProcessed: false + )]) + + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + var restored = try await fixture.index.query(.history, text: "restored", limit: 50).map(\.id) + while restored.isEmpty, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(25)) + restored = try await fixture.index.query(.history, text: "restored", limit: 50).map(\.id) + } + XCTAssertEqual(restored, [saved.id]) + let stale = try await fixture.index.query(.history, text: "saved", limit: 50) + XCTAssertTrue(stale.isEmpty, "The text from before the restore must not survive in the index") + await history.finishPendingWrites() + } + } + + func testSubscribingAfterLoadingIndexesCurrentSnapshot() async throws { + try await self.withFixture { fixture in + let saved = self.entry("startup saved dictation") + let stale = self.entry("startup stale index record") + try fixture.defaults.set(JSONEncoder().encode([saved]), forKey: "TranscriptionHistoryEntries") + try await fixture.index.reconcile(.history, with: [stale.searchRecord]) + let history = TranscriptionHistoryStore(writer: fixture.writer) + try await history.waitUntilLoaded() + + let coordinator = SearchIndexCoordinator(index: fixture.index) + defer { withExtendedLifetime(coordinator) {} } + coordinator.start(historyStore: history) + try await self.waitForIndexedIDs([saved.id], in: fixture.index) + } + } +} diff --git a/Tests/FluidDictationIntegrationTests/SettingsNavigationStateTests.swift b/Tests/FluidDictationIntegrationTests/SettingsNavigationStateTests.swift index 0f2528716..89e4a31a9 100644 --- a/Tests/FluidDictationIntegrationTests/SettingsNavigationStateTests.swift +++ b/Tests/FluidDictationIntegrationTests/SettingsNavigationStateTests.swift @@ -76,7 +76,7 @@ final class SettingsNavigationStateTests: XCTestCase { XCTAssertTrue(window.makeFirstResponder(searchField)) XCTAssertNotNil(searchField.currentEditor()) - SettingsSearchField.resignFocusIfNeeded(from: searchField, isActive: false) + SidebarSearchField.resignFocusIfNeeded(from: searchField, isActive: false) XCTAssertNil(searchField.currentEditor()) } @@ -169,4 +169,67 @@ final class SettingsNavigationStateTests: XCTestCase { ) XCTAssertEqual(SettingsSearchIndex.preferredSection(current: .audio, results: []), .audio) } + + func testSettingsSearchOmitsTargetsUnavailableInTheCurrentState() { + let availability = SettingsSearchAvailability( + microphoneAuthorized: true, + accessibilityEnabled: true, + savesTranscriptionHistory: false, + savesAudioWithTranscriptionHistory: false, + overlayAtBottom: false + ) + + let permissionTargets = SettingsSearchIndex.results( + for: "permission", + availability: availability + ).map(\.target) + + XCTAssertFalse(permissionTargets.contains(.microphonePermission)) + XCTAssertFalse(permissionTargets.contains(.accessibilityPermission)) + XCTAssertFalse(SettingsSearchIndex.results( + for: "audio storage", + availability: availability + ).contains { $0.target == .audioStorage }) + XCTAssertFalse(SettingsSearchIndex.results( + for: "bottom offset", + availability: availability + ).contains { $0.target == .bottomOffset }) + } + + func testSettingsSearchOmitsControlsHiddenWithoutAccessibility() { + let availability = SettingsSearchAvailability( + microphoneAuthorized: true, + accessibilityEnabled: false, + savesTranscriptionHistory: true, + savesAudioWithTranscriptionHistory: true, + overlayAtBottom: true + ) + let gatedTargets: [(SettingsSearchTarget, String)] = [ + (.primaryDictationShortcuts, "primary dictation shortcuts"), + (.commandModeShortcut, "command mode shortcut"), + (.editModeShortcut, "edit mode shortcut"), + (.cancelRecordingShortcut, "cancel recording shortcut"), + (.pasteLastTranscriptionShortcut, "paste last transcription shortcut"), + (.activationMode, "activation mode"), + (.copyToClipboard, "copy to clipboard"), + (.textInsertionMode, "text insertion mode"), + (.spokenSend, "spoken send"), + (.transcriptionHistory, "save transcription history"), + (.audioHistory, "save audio with history"), + (.audioStorage, "audio storage"), + (.usageStreak, "usage streak"), + (.skipSilentRecordings, "skip silent recordings"), + (.pauseMedia, "pause media during transcription"), + (.dictionarySuggestions, "dictionary suggestions"), + (.analyticsPrivacy, "detailed anonymous analytics"), + ] + + for (target, query) in gatedTargets { + XCTAssertFalse( + SettingsSearchIndex.results(for: query, availability: availability) + .contains { $0.target == target }, + "\(target) is not rendered without Accessibility permission" + ) + } + } } diff --git a/Tests/FluidDictationIntegrationTests/ZeppelinDataLayerTests.swift b/Tests/FluidDictationIntegrationTests/ZeppelinDataLayerTests.swift new file mode 100644 index 000000000..e45948e90 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/ZeppelinDataLayerTests.swift @@ -0,0 +1,142 @@ +@testable import FluidVoice_Debug +import Foundation +import XCTest +import ZeppelinEmbed + +/// `FluidZeppelinRoot`: namespaces are independent, one handle is shared per +/// namespace, and a namespace that cannot be read is reset rather than left broken. +final class ZeppelinDataLayerTests: XCTestCase { + private var root: URL! + + override func setUpWithError() throws { + self.root = FileManager.default.temporaryDirectory + .appendingPathComponent("ZeppelinDataLayerTests-\(UUID().uuidString)", isDirectory: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: self.root) + self.root = nil + } + + // MARK: - Fixtures + + private static let spec = NamespaceSpec( + attributes: [AttributeDefinition(id: 1, name: "kind", type: .u64, nullable: false)], + vectorSpace: nil + ) + + private func seed(_ store: ZeppelinStore, ids: ClosedRange) async throws { + _ = try await store.upsert(ids.map { id in + IngestDocument( + id: DocumentID(high: 0, low: id), + revision: 1, + timestamp: Int64(id), + vector: [], + text: "row \(id)", + attributes: [1: .u64(1)] + ) + }) + try await store.seal() + } + + private func corruptManifest(of name: String) throws { + let manifest = self.root + .appendingPathComponent(name, isDirectory: true) + .appendingPathComponent("manifest.ze") + let original = try Data(contentsOf: manifest) + try original.prefix(original.count / 2).write(to: manifest) + } + + private func directories() throws -> [String] { + try FileManager.default.contentsOfDirectory(atPath: self.root.path).sorted() + } + + // MARK: - Namespaces + + func testANamespaceAddedLaterLeavesExistingOnesUntouched() async throws { + let database = FluidZeppelinRoot(root: self.root) + let first = try await database.namespace("first", spec: Self.spec) + try await self.seed(first, ids: 1...5) + + let second = try await database.namespace("second", spec: NamespaceSpec(attributes: [], vectorSpace: nil)) + _ = try await second.upsert([ + IngestDocument(id: DocumentID(high: 0, low: 1), revision: 1, timestamp: 0, vector: [], text: "hello"), + ]) + try await second.seal() + + let count = try await first.count().count + XCTAssertEqual(count, 5, "adding a namespace disturbed an existing one") + let names = try await ZeppelinStore.listNamespaces(root: self.root) + XCTAssertEqual(names, ["first", "second"]) + await database.closeAll() + } + + func testTheSameNamespaceReturnsOneSharedHandle() async throws { + let database = FluidZeppelinRoot(root: self.root) + let first = try await database.namespace("first", spec: Self.spec) + let second = try await database.namespace("first", spec: Self.spec) + XCTAssertTrue(first === second) + await database.closeAll() + } + + /// Two callers asking for the same namespace before either open has finished + /// must share one open rather than the second failing on the writer lock. + func testConcurrentFirstOpensShareOneHandle() async throws { + let database = FluidZeppelinRoot(root: self.root) + async let first = database.namespace("first", spec: Self.spec) + async let second = database.namespace("first", spec: Self.spec) + let (a, b) = try await (first, second) + XCTAssertTrue(a === b) + await database.closeAll() + } + + // MARK: - Unreadable namespaces are reset + + func testACorruptNamespaceIsResetAndReopenedEmpty() async throws { + let first = FluidZeppelinRoot(root: self.root) + try await self.seed(first.namespace("first", spec: Self.spec), ids: 1...4) + await first.closeAll() + try self.corruptManifest(of: "first") + + let second = FluidZeppelinRoot(root: self.root) + let reopened = try await second.namespace("first", spec: Self.spec) + let count = try await reopened.count().count + XCTAssertEqual(count, 0) + XCTAssertEqual(try self.directories(), ["first"], "nothing but the live namespace should remain") + await second.closeAll() + } + + func testASchemaMismatchIsResetToo() async throws { + let first = FluidZeppelinRoot(root: self.root) + try await self.seed(first.namespace("first", spec: Self.spec), ids: 1...2) + await first.closeAll() + + let widened = NamespaceSpec( + attributes: Self.spec.attributes + [ + AttributeDefinition(id: 2, name: "app", type: .dictionaryString, nullable: true), + ], + vectorSpace: nil + ) + let second = FluidZeppelinRoot(root: self.root) + let reopened = try await second.namespace("first", spec: widened) + let count = try await reopened.count().count + XCTAssertEqual(count, 0) + await second.closeAll() + } + + func testAHealthyNamespaceKeepsItsRowsAcrossLaunches() async throws { + let first = FluidZeppelinRoot(root: self.root) + try await self.seed(first.namespace("first", spec: Self.spec), ids: 1...3) + await first.closeAll() + try self.corruptManifest(of: "first") + + let second = FluidZeppelinRoot(root: self.root) + try await self.seed(second.namespace("first", spec: Self.spec), ids: 10...12) + await second.closeAll() + + let third = FluidZeppelinRoot(root: self.root) + let count = try await third.namespace("first", spec: Self.spec).count().count + XCTAssertEqual(count, 3, "the rebuilt namespace should have kept its rows") + await third.closeAll() + } +}