Files
byre/mac/Sources/Byre/Find.swift
T

253 lines
7.8 KiB
Swift

import SwiftUI
private let findOptions: String.CompareOptions = [.caseInsensitive, .diacriticInsensitive]
extension EnvironmentValues {
@Entry var findQuery = ""
}
struct FindHit {
let id: Int
let path: [Int]
}
func matches(_ text: String, _ query: String) -> Bool {
!query.isEmpty && text.range(of: query, options: findOptions) != nil
}
func highlighted(_ a: AttributedString, _ query: String) -> AttributedString {
if query.isEmpty {
return a
}
var out = a
var from = out.startIndex
while let r = out[from...].range(of: query, options: findOptions) {
out[r].backgroundColor = Palette.findHit
#if os(macOS)
out[r].appKit.backgroundColor = NSColor(Palette.findHit)
#else
out[r].uiKit.backgroundColor = UIColor(Palette.findHit)
#endif
from = r.upperBound
}
return out
}
func highlighted(_ s: String, _ query: String) -> AttributedString {
highlighted(AttributedString(s), query)
}
func highlight(_ s: NSMutableAttributedString, _ query: String) {
if query.isEmpty {
return
}
let text = s.string as NSString
var from = 0
while from < text.length {
let r = text.range(of: query, options: findOptions, range: NSRange(location: from, length: text.length - from))
if r.location == NSNotFound {
return
}
s.addAttribute(.backgroundColor, value: PlatformColor(Palette.findHit), range: r)
from = NSMaxRange(r)
}
}
func mdText(_ blocks: [MDBlock]) -> String {
blocks.map(mdText).joined(separator: "\n")
}
private func mdText(_ block: MDBlock) -> String {
switch block {
case .paragraph(let inlines), .heading(_, let inlines):
return inlineText(inlines)
case .rule:
return ""
case .quote(let blocks):
return mdText(blocks)
case .list(_, _, let items, _):
return items.map { inlineText($0.inline) + "\n" + mdText($0.blocks) }.joined(separator: "\n")
case .code(let code), .diagram(let code):
return code
case .diff(let lines):
return lines.joined(separator: "\n")
case .details(let summary, let body):
return inlineText(summary) + "\n" + mdText(body)
case .table(let head, let rows):
return ([head] + rows).map { $0.map(inlineText).joined(separator: "\t") }.joined(separator: "\n")
}
}
private func inlineText(_ inlines: [MDInline]) -> String {
inlines.map { item in
switch item {
case .text(let t), .code(let t):
t
case .strong(let inner), .em(let inner), .link(_, let inner), .mark(_, let inner):
inlineText(inner)
case .image(let src, let alt):
alt.isEmpty ? src : alt
case .lineBreak:
"\n"
}
}.joined()
}
private func noteText(_ note: Note?) -> String {
(note?.parts ?? []).map { part in
if case .text(let t) = part {
return t
}
return ""
}.joined()
}
@MainActor
private func ownText(_ row: Row) -> String {
switch row.kind {
case .user(_, _, _, let md):
return mdText(md ?? [])
case .byrePrompt(let brief, _, let md):
return brief + "\n" + mdText(md)
case .agent(let md), .subagentMessage(_, let md), .thinking(let md), .ready(_, let md, _):
return mdText(md)
case .stream:
return mdText(row.md)
case .tool(let name, let brief, let input):
return [name, brief, input, row.result ?? ""].joined(separator: "\n")
case .subagent(let title, let description, let prompt):
return [title, description, mdText(prompt), row.result ?? ""].joined(separator: "\n")
case .reviewer:
return (row.review?.stage ?? "") + "\n" + row.text
case .orphan(let text):
return text
case .turnEnd(let error):
return error ?? ""
case .note(let note):
return noteText(note)
case .review(let note, let md):
return noteText(note) + "\n" + mdText(md)
case .finding(let index):
guard let review = row.review, findings(review).indices.contains(index) else {
return ""
}
let f = findings(review)[index]
return [str(f, "title") ?? "", str(f, "detail") ?? "", findingWhere(f)].joined(separator: "\n")
case .findingResponse(_, _, let response, let reason):
return response + "\n" + reason
case .handoff:
return noteText(row.summary)
case .question:
let p = row.question?.payload.value ?? [:]
return (str(p, "question") ?? "") + "\n" + (str(p, "context") ?? "")
case .group, .snapshot:
return ""
}
}
@MainActor
func searchRows(_ rows: [Row], _ query: String) -> [FindHit] {
var out: [FindHit] = []
func walk(_ rows: [Row], _ path: [Int]) {
for row in rows where !row.hidden {
if matches(ownText(row), query) {
out.append(FindHit(id: row.id, path: path))
}
if let body = row.body {
walk(body.rows, path + [row.id])
}
}
}
if !query.isEmpty {
walk(rows, [])
}
return out
}
@MainActor
struct FindField: View {
let placeholder: String
@Binding var text: String
let focusSignal: Int
var submit: (Bool) -> Void = { _ in }
let end: () -> Void
@Environment(Overlays.self) private var overlays
@FocusState private var focused: Bool
@ViewState private var token = newID("fnd")
var body: some View {
TextField(placeholder, text: $text)
.modifier(FieldStyle(focused: focused))
.focused($focused)
.onSubmit {
#if os(macOS)
submit(NSEvent.modifierFlags.contains(.shift))
#else
submit(false)
#endif
focused = true
}
.onChange(of: focusSignal, initial: true) { old, new in
if new != 0 && (old != new || !focused) {
focused = true
}
}
.onChange(of: focused, initial: true) {
if focused {
overlays.endFind = (token, end)
} else if overlays.endFind?.token == token {
overlays.endFind = nil
}
}
.onDisappear {
if overlays.endFind?.token == token {
overlays.endFind = nil
}
}
}
}
struct FindBar: View {
let model: ChatModel
var body: some View {
@Bindable var model = model
let count = model.findHits.count
HStack(spacing: 8) {
FindField(placeholder: "Find in transcript", text: $model.findQuery, focusSignal: model.findFocus) { back in
model.stepFind(back ? -1 : 1)
} end: {
model.closeFind()
}
Text(model.findQuery.isEmpty ? "" : count == 0 ? "No matches" : "\(model.findIndex + 1) of \(count)")
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.monospacedDigit()
.fixedSize()
Group {
Button("↑") {
model.stepFind(-1)
}
.help("Previous match (Shift+Enter)")
.accessibilityLabel("Previous match")
Button("↓") {
model.stepFind(1)
}
.help("Next match (Enter)")
.accessibilityLabel("Next match")
}
.disabled(count == 0)
Button("Done") {
model.closeFind()
}
.help("Close the find bar (Esc)")
}
.buttonStyle(WebButtonStyle())
.padding(.horizontal, 16)
.padding(.vertical, 6)
.overlay(alignment: .bottom) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
}