File size: 5,602 Bytes
b8370f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
#if os(macOS)
import SwiftUI

struct ChatTranscriptSearchResults: Equatable {
    let messageIDs: [UUID]

    init(rows: [ChatTranscriptRow], query: String) {
        let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
        self.messageIDs = query.isEmpty ? [] : rows.compactMap { row in
            guard case let .message(message) = row,
                  ["user", "assistant"].contains(message.role.lowercased()),
                  ChatMessageVisibleText.visibleText(in: message).localizedStandardContains(query)
            else { return nil }
            return message.id
        }
    }

    func selection(after current: UUID?, backwards: Bool = false) -> UUID? {
        guard !self.messageIDs.isEmpty else { return nil }
        guard let current, let index = self.messageIDs.firstIndex(of: current) else {
            return backwards ? self.messageIDs.last : self.messageIDs.first
        }
        let offset = backwards ? self.messageIDs.count - 1 : 1
        return self.messageIDs[(index + offset) % self.messageIDs.count]
    }
}

struct ChatTranscriptSearch: ViewModifier {
    let rows: [ChatTranscriptRow]
    let sessionKey: String
    let isEnabled: Bool
    @Binding var selectedMessageID: UUID?
    @Binding var isPresented: Bool
    let onSelect: (UUID) -> Void
    @State private var query = ""
    @FocusState private var isFocused: Bool

    func body(content: Content) -> some View {
        let results = ChatTranscriptSearchResults(rows: self.rows, query: self.query)
        return content
            .safeAreaInset(edge: .top, spacing: 0) {
                if self.isPresented { self.searchBar(results: results) }
            }
            .toolbar {
                if self.isEnabled {
                    ToolbarItem(placement: .primaryAction) {
                        Button {
                            self.isPresented = true
                            self.isFocused = true
                        } label: {
                            Label("Find in Conversation", systemImage: "magnifyingglass")
                        }
                        .keyboardShortcut("f", modifiers: .command)
                        .help("Find in conversation (⌘F)")
                    }
                }
            }
            .onChange(of: results) { _, results in
                guard self.isPresented else { return }
                if !results.messageIDs.contains(where: { $0 == self.selectedMessageID }) {
                    self.select(results.messageIDs.first)
                }
            }
            .onChange(of: self.sessionKey) { _, _ in self.close() }
            .onChange(of: self.isPresented) { _, isPresented in
                if !isPresented { self.close() }
            }
    }

    private func searchBar(results: ChatTranscriptSearchResults) -> some View {
        HStack(spacing: 10) {
            Image(systemName: "magnifyingglass")
                .foregroundStyle(.secondary)
            TextField("Find in loaded messages", text: self.$query)
                .textFieldStyle(.plain)
                .focused(self.$isFocused)
                .onAppear { self.isFocused = true }
                .onSubmit { self.selectNext(in: results) }
                .onExitCommand { self.close() }
                .accessibilityIdentifier("chat-transcript-search")

            Text(self.resultLabel(results: results))
                .font(OpenClawChatTypography.caption)
                .foregroundStyle(.secondary)
                .monospacedDigit()
                .fixedSize()

            Button {
                self.selectNext(in: results, backwards: true)
            } label: {
                Label("Previous Match", systemImage: "chevron.up")
            }
            .keyboardShortcut("g", modifiers: [.command, .shift])
            .disabled(results.messageIDs.isEmpty)
            .help("Previous match (⇧⌘G)")

            Button {
                self.selectNext(in: results)
            } label: {
                Label("Next Match", systemImage: "chevron.down")
            }
            .keyboardShortcut("g", modifiers: .command)
            .disabled(results.messageIDs.isEmpty)
            .help("Next match (⌘G)")

            Button(action: self.close) {
                Label("Close Find", systemImage: "xmark")
            }
            .help("Close find (Escape)")
        }
        .labelStyle(.iconOnly)
        .buttonStyle(.borderless)
        .padding(.horizontal, 16)
        .padding(.vertical, 12)
        .background(.bar)
        .overlay(alignment: .bottom) {
            Divider()
        }
    }

    private func resultLabel(results: ChatTranscriptSearchResults) -> String {
        if self.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "" }
        guard let selectedMessageID,
              let index = results.messageIDs.firstIndex(of: selectedMessageID)
        else { return String(localized: "No matches") }
        return String(
            format: String(localized: "%lld of %lld"),
            Int64(index + 1),
            Int64(results.messageIDs.count))
    }

    private func selectNext(in results: ChatTranscriptSearchResults, backwards: Bool = false) {
        self.select(results.selection(after: self.selectedMessageID, backwards: backwards))
    }

    private func select(_ messageID: UUID?) {
        self.selectedMessageID = messageID
        if let messageID { self.onSelect(messageID) }
    }

    private func close() {
        self.isPresented = false
        self.query = ""
        self.selectedMessageID = nil
    }
}
#endif