From fd0529b94940ae5cdfee5a147d9b7f86452329c1 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Fri, 23 Oct 2026 09:50:35 -0700 Subject: [PATCH] =?UTF-8?q?Add=20=E2=8C=98F=20find=20in=20transcripts=20an?= =?UTF-8?q?d=20a=20TODO=20filter=20on=20the=20project=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mac/Sources/Byre/ByreApp.swift | 1 + mac/Sources/Byre/Chat.swift | 116 +++++++++++-- mac/Sources/Byre/Dialogs.swift | 14 ++ mac/Sources/Byre/Find.swift | 252 ++++++++++++++++++++++++++++ mac/Sources/Byre/Format.swift | 5 +- mac/Sources/Byre/MarkdownView.swift | 21 ++- mac/Sources/Byre/Palette.swift | 1 + mac/Sources/Byre/PaneFocus.swift | 4 + mac/Sources/Byre/Panes.swift | 2 +- mac/Sources/Byre/Project.swift | 42 ++++- mac/Sources/Byre/Switcher.swift | 10 +- 11 files changed, 434 insertions(+), 34 deletions(-) create mode 100644 mac/Sources/Byre/Find.swift diff --git a/mac/Sources/Byre/ByreApp.swift b/mac/Sources/Byre/ByreApp.swift index 701e8be..3555219 100644 --- a/mac/Sources/Byre/ByreApp.swift +++ b/mac/Sources/Byre/ByreApp.swift @@ -111,6 +111,7 @@ struct ByreCommands: Commands { goButton("Project", "p") goButton("Lead", "l") goButton("Merge", "s") + goButton("Find", "f") serverButton("Previous Server", "[", -1) serverButton("Next Server", "]", 1) Divider() diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index 9aa36ff..41d87fe 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -57,6 +57,18 @@ final class ChatModel { private(set) var scrollRequest: (seq: Int, id: Int?, anchor: UnitPoint)? private(set) var fetching = false private(set) var older: [ByreEvent]? + private(set) var findOpen = false + private(set) var findFocus = 0 + var findQuery = "" { + didSet { + if findQuery != oldValue { + refreshFind(jump: true) + } + } + } + private(set) var findHits: [FindHit] = [] + private(set) var findIndex = 0 + private(set) var findPath: Set = [] @ObservationIgnored var stuck = true @ObservationIgnored var prepended = false @ObservationIgnored var released: Int? @@ -261,6 +273,55 @@ final class ChatModel { settled = feed?.ready == true } jump() + if !findQuery.isEmpty { + refreshFind(jump: false) + } + } + + func openFind() { + findOpen = true + findFocus += 1 + } + + func closeFind() { + findOpen = false + findQuery = "" + } + + func stepFind(_ by: Int) { + if findHits.isEmpty { + return + } + findIndex = (findIndex + by + findHits.count) % findHits.count + revealFind() + } + + private func refreshFind(jump: Bool) { + let current = findHits.indices.contains(findIndex) ? findHits[findIndex].id : nil + findHits = searchRows(builder.root.rows, findQuery) + if !findQuery.isEmpty { + loadOlder() + } + if jump || (current == nil && !findHits.isEmpty) { + findIndex = max(0, findHits.count - 1) + revealFind() + return + } + findIndex = findHits.firstIndex { $0.id == current } ?? min(findIndex, max(0, findHits.count - 1)) + } + + private func revealFind() { + guard findHits.indices.contains(findIndex) else { + findPath = [] + return + } + let hit = findHits[findIndex] + findPath = Set(hit.path + [hit.id]) + release() + stuck = false + DispatchQueue.main.async { [weak self] in + self?.requestScroll(hit.id, .top) + } } func takeFocus() -> Bool { @@ -483,6 +544,9 @@ final class ChatModel { let n = builder.root.rows.count builder.prepend(events) prepended = builder.root.rows.count > n || !hasOlder + if !findQuery.isEmpty { + refreshFind(jump: false) + } if !prepended { loadOlder() } @@ -582,13 +646,18 @@ final class ChatModel { struct ChatView: View { let kind: ChatKind @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays @ViewState private var model: ChatModel? var body: some View { VStack(spacing: 0) { if let model { ChatHead(model: model) + if model.findOpen { + FindBar(model: model) + } TranscriptScroll(model: model) + .environment(\.findQuery, model.findOpen ? model.findQuery : "") if let key = model.key { ComposerView(chat: model, key: key) } @@ -607,6 +676,11 @@ struct ChatView: View { .onChange(of: store.jump) { model?.jump() } + .onChange(of: kind == .lead ? overlays.findLead : overlays.findTask) { + if model?.key != nil { + model?.openFind() + } + } } } @@ -1173,6 +1247,7 @@ struct DetailsBox: View { var working = false var fade = false var status: (text: String, color: Color)? = nil + var reveal = false @ViewBuilder let summary: Summary @ViewBuilder let content: Content @Environment(\.paneAccent) private var accent @@ -1185,7 +1260,7 @@ struct DetailsBox: View { open.toggle() } label: { HStack(alignment: .firstTextBaseline, spacing: 4) { - Text(open ? "▾" : "▸") + Text(open || reveal ? "▾" : "▸") .foregroundStyle(Palette.muted) summary Spacer(minLength: 0) @@ -1199,7 +1274,7 @@ struct DetailsBox: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - if open { + if open || reveal { content } } @@ -1216,6 +1291,11 @@ struct DetailsBox: View { .onAppear { shown = true } + .onChange(of: reveal, initial: true) { + if reveal { + open = true + } + } } } @@ -1246,6 +1326,11 @@ struct RowView: View { let model: ChatModel let nested: Bool @Environment(Store.self) private var store + @Environment(\.findQuery) private var query + + private var reveal: Bool { + model.findPath.contains(row.id) + } var body: some View { if !row.hidden { @@ -1289,8 +1374,8 @@ struct RowView: View { } case .byrePrompt(let brief, let when, let blocks): - DetailsBox(nested: nested) { - Text(styled([("byre", 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)])) + DetailsBox(nested: nested, reveal: reveal) { + Text(highlighted(styled([("byre", 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)]), query)) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 12) WhenText(when: when).font(Fonts.font(12)).foregroundStyle(Palette.muted) @@ -1332,9 +1417,9 @@ struct RowView: View { } case .tool(let name, let brief, let input): - DetailsBox(nested: nested, working: model.working(row), fade: model.fades(row)) { + DetailsBox(nested: nested, working: model.working(row), fade: model.fades(row), reveal: reveal) { SubagentTag(show: row.tag) - Text(styled([(name, 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)])) + Text(highlighted(styled([(name, 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)]), query)) } content: { VStack(alignment: .leading, spacing: 6) { PreBox(text: input) @@ -1344,9 +1429,9 @@ struct RowView: View { } case .subagent(let title, let description, let prompt): - DetailsBox(nested: nested, working: model.working(row), fade: model.fades(row), status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent)) { + DetailsBox(nested: nested, working: model.working(row), fade: model.fades(row), status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent), reveal: reveal) { SubagentTag(show: row.tag) - Text(styled([(title, 600, nil), (" ", 400, nil), (description, 400, Palette.muted)])) + Text(highlighted(styled([(title, 600, nil), (" ", 400, nil), (description, 400, Palette.muted)]), query)) } content: { VStack(alignment: .leading, spacing: 6) { MarkdownView(blocks: prompt, ctx: md) @@ -1359,8 +1444,8 @@ struct RowView: View { } case .reviewer(let agentModel): - DetailsBox(nested: nested, working: row.active, fade: model.fades(row), status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent)) { - Text(styled([(row.review?.stage ?? "review", 600, nil), (agentModel.isEmpty ? "" : " · \(agentModel)", 400, Palette.muted), (row.text.isEmpty ? "" : " \(row.text)", 400, Palette.muted)])) + DetailsBox(nested: nested, working: row.active, fade: model.fades(row), status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent), reveal: reveal) { + Text(highlighted(styled([(row.review?.stage ?? "review", 600, nil), (agentModel.isEmpty ? "" : " · \(agentModel)", 400, Palette.muted), (row.text.isEmpty ? "" : " \(row.text)", 400, Palette.muted)]), query)) .lineLimit(1) } content: { VStack(alignment: .leading, spacing: 6) { @@ -1373,7 +1458,7 @@ struct RowView: View { } case .group: - DetailsBox(nested: nested, working: model.working(row), fade: model.fades(row)) { + DetailsBox(nested: nested, working: model.working(row), fade: model.fades(row), reveal: reveal) { SubagentTag(show: row.tag) RunSummary(calls: row.calls) } content: { @@ -1391,7 +1476,7 @@ struct RowView: View { case .turnEnd(let error): if let error { - Text(error) + Text(highlighted(error, query)) .font(Fonts.font(11)) .foregroundStyle(Palette.danger) } else { @@ -1426,7 +1511,7 @@ struct RowView: View { ReadyBubble(when: when, blocks: blocks, discard: discard, taskId: row.taskId, latest: row.id == model.builder.lastReady, unmerged: model.hasUnmerged) case .handoff: - DetailsBox(boxed: false) { + DetailsBox(boxed: false, reveal: reveal) { NoteView(note: row.summary ?? Note(parts: []), cancelVisible: false) } content: { VStack(alignment: .leading, spacing: 12) { @@ -1446,7 +1531,7 @@ struct RowView: View { case .question: if let item = row.question { - QuestionBubble(item: item, inherited: row.taskId != model.taskId) + QuestionBubble(item: item, inherited: row.taskId != model.taskId, reveal: reveal) } } } @@ -1455,6 +1540,7 @@ struct RowView: View { private struct QuestionBubble: View { let item: InboxItem let inherited: Bool + let reveal: Bool var body: some View { let open = item.state == .open && !inherited @@ -1463,7 +1549,7 @@ private struct QuestionBubble: View { QuestionCard(item: item) .id("\(item.id)/\(item.state.rawValue)/\(item.responses.count)") } else { - DetailsBox(boxed: false) { + DetailsBox(boxed: false, reveal: reveal) { VStack(alignment: .leading, spacing: 4) { AttentionHeader(label: "asked", when: item.createdAt, open: false) MarkdownView(blocks: parseMarkdown(jsString(item.payload.value["question"] ?? "")), ctx: MDContext(taskId: item.taskId)) diff --git a/mac/Sources/Byre/Dialogs.swift b/mac/Sources/Byre/Dialogs.swift index e5da92d..a5888c3 100644 --- a/mac/Sources/Byre/Dialogs.swift +++ b/mac/Sources/Byre/Dialogs.swift @@ -56,6 +56,9 @@ final class Overlays { var leftTab = LeftTab.lead var leadFocus = 0 var tabFocus = 0 + var findLead = 0 + var findTask = 0 + @ObservationIgnored var endFind: (token: String, run: () -> Void)? var listKeys = false var listCursor: String? var projectTab = ProjectTab.tasks @@ -148,6 +151,14 @@ final class Overlays { #endif } + func cancelFind() -> Bool { + guard let endFind else { + return false + } + endFind.run() + return true + } + func closeTop() { if !alerts.isEmpty { alerts.removeLast() @@ -201,6 +212,9 @@ final class Overlays { if !dialogOpen && flags.contains(.control) && !flags.contains(.command) && goKeys.contains(chars) { return ctrl(chars) } + if !dialogOpen && flags.subtracting(.capsLock) == .command && chars == "f" { + return ctrl(chars) + } let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false if code == 53 { if isOpen { diff --git a/mac/Sources/Byre/Find.swift b/mac/Sources/Byre/Find.swift new file mode 100644 index 0000000..5d7ae12 --- /dev/null +++ b/mac/Sources/Byre/Find.swift @@ -0,0 +1,252 @@ +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) + } + } +} diff --git a/mac/Sources/Byre/Format.swift b/mac/Sources/Byre/Format.swift index 5a837c3..d48738c 100644 --- a/mac/Sources/Byre/Format.swift +++ b/mac/Sources/Byre/Format.swift @@ -233,11 +233,12 @@ struct PreBox: View { let text: String var maxHeight: CGFloat? = 320 var size: CGFloat = 12 + @Environment(\.findQuery) private var query var body: some View { Copyable(text: text) { ScrollView { - Text(verbatim: text) + Text(highlighted(text, query)) .font(Fonts.font(size, mono: true)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) @@ -245,7 +246,7 @@ struct PreBox: View { .padding(.vertical, 6) } .scrollBounceBehavior(.basedOnSize) - .frame(maxHeight: maxHeight) + .frame(maxHeight: matches(text, query) ? nil : maxHeight) .fixedSize(horizontal: false, vertical: true) .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border)) diff --git a/mac/Sources/Byre/MarkdownView.swift b/mac/Sources/Byre/MarkdownView.swift index 21242f7..eb9a5b3 100644 --- a/mac/Sources/Byre/MarkdownView.swift +++ b/mac/Sources/Byre/MarkdownView.swift @@ -485,6 +485,7 @@ struct MarkdownView: View { let blocks: [MDBlock] var ctx = MDContext() @Environment(Store.self) private var store + @Environment(\.findQuery) private var query var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -502,7 +503,9 @@ struct MarkdownView: View { private func flow(_ blocks: [MDBlock]) -> NSAttributedString { var builder = FlowBuilder(store: store, ctx: ctx) - return builder.build(blocks) + let out = builder.build(blocks) + highlight(out, query) + return out } private var runs: [MDRun] { @@ -564,7 +567,7 @@ private struct FlowBuilder { self.ctx = ctx } - mutating func build(_ blocks: [MDBlock]) -> NSAttributedString { + mutating func build(_ blocks: [MDBlock]) -> NSMutableAttributedString { self.blocks(blocks, 0) if out.length > 0 { out.deleteCharacters(in: NSRange(location: out.length - 1, length: 1)) @@ -918,13 +921,15 @@ struct InlineView: View { @Environment(Store.self) private var store @Environment(\.choiceTap) private var choiceTap @Environment(\.centerImages) private var centerImages + @Environment(\.findQuery) private var query var body: some View { let segs = InlineRenderer(store: store, size: ctx.size, taskId: ctx.taskId, project: ctx.project, weight: ctx.weight, dir: ctx.dir).segments(inlines) VStack(alignment: .leading, spacing: 4) { ForEach(Array(segs.enumerated()), id: \.offset) { _, seg in switch seg { - case .text(let a): + case .text(let plain): + let a = highlighted(plain, query) if choiceTap != nil { FlowText(text: flowString(a)) } else { @@ -960,6 +965,7 @@ struct InlineView: View { private struct BlockView: View { let block: MDBlock let ctx: MDContext + @Environment(\.findQuery) private var query var body: some View { switch block { @@ -984,7 +990,7 @@ private struct BlockView: View { case .code(let code): Copyable(text: code) { ScrollView(.horizontal) { - Text(verbatim: code) + Text(highlighted(code, query)) .font(Fonts.font(ctx.size, mono: true)) .foregroundStyle(Palette.text) .textSelection(.enabled) @@ -1000,7 +1006,7 @@ private struct BlockView: View { case .diff(let lines): DiffBlock(lines: lines, size: ctx.size) case .details(let summary, let body): - DetailsBlock(summary: summary, blocks: body, ctx: ctx) + DetailsBlock(summary: summary, blocks: body, ctx: ctx, reveal: matches(mdText(body), query)) case .diagram(let source): BoxDiagramView(source: source) case .table(let head, let rows): @@ -1115,6 +1121,7 @@ private struct DetailsBlock: View { let summary: [MDInline] let blocks: [MDBlock] let ctx: MDContext + let reveal: Bool @ViewState private var open = false var body: some View { @@ -1123,14 +1130,14 @@ private struct DetailsBlock: View { open.toggle() } label: { HStack(alignment: .firstTextBaseline, spacing: 4) { - Text(open ? "▾" : "▸") + Text(open || reveal ? "▾" : "▸") .foregroundStyle(Palette.accent) InlineView(inlines: summary, ctx: MDContext(size: ctx.size, color: Palette.accent, taskId: ctx.taskId, project: ctx.project, dir: ctx.dir)) } .contentShape(Rectangle()) } .buttonStyle(.plain) - if open { + if open || reveal { MarkdownView(blocks: blocks, ctx: ctx) .padding(.leading, 11) .overlay(alignment: .leading) { diff --git a/mac/Sources/Byre/Palette.swift b/mac/Sources/Byre/Palette.swift index 1822c07..bce331f 100644 --- a/mac/Sources/Byre/Palette.swift +++ b/mac/Sources/Byre/Palette.swift @@ -15,6 +15,7 @@ enum Palette { static let warnTint = dynamic(0xfdf3dc, 0x352a14) static let ok = dynamic(0x1a7f37, 0x7fd88f) static let okTint = dynamic(0xe5f4e9, 0x1b3322) + static let findHit = dynamic(0xfbe38e, 0x5a4a10) static let task0 = dynamic(0xa4273f, 0xfe9fa6) static let task1 = dynamic(0x9f3501, 0xffa383) static let task2 = dynamic(0x376e00, 0x99d079) diff --git a/mac/Sources/Byre/PaneFocus.swift b/mac/Sources/Byre/PaneFocus.swift index e3d8103..a6198ec 100644 --- a/mac/Sources/Byre/PaneFocus.swift +++ b/mac/Sources/Byre/PaneFocus.swift @@ -125,6 +125,10 @@ final class PaneFocus { #endif } + var inTaskPane: Bool { + inside + } + func setFrame(_ frame: CGRect) { paneFrame = frame } diff --git a/mac/Sources/Byre/Panes.swift b/mac/Sources/Byre/Panes.swift index e5222a9..e7b9949 100644 --- a/mac/Sources/Byre/Panes.swift +++ b/mac/Sources/Byre/Panes.swift @@ -60,7 +60,7 @@ struct MainView: View { overlays.install { key in ctrlGo(key, store, overlays) } escape: { - store.closeSheet() + overlays.cancelFind() || store.closeSheet() } list: { code in listKey(code, store, overlays) } diff --git a/mac/Sources/Byre/Project.swift b/mac/Sources/Byre/Project.swift index 7ce1620..acb0053 100644 --- a/mac/Sources/Byre/Project.swift +++ b/mac/Sources/Byre/Project.swift @@ -11,6 +11,8 @@ struct ProjectView: View { @Environment(Overlays.self) private var overlays @ViewState private var todos: [String: Result] = [:] @ViewState private var off: (() -> Void)? + @ViewState private var filter = "" + @ViewState private var filterFocus = 0 var body: some View { VStack(spacing: 0) { @@ -37,7 +39,7 @@ struct ProjectView: View { } else { ScrollView { if let p = store.focusedProject() { - ProjectBlock(project: p, todo: todos[p.id], settings: overlays.projectTab == .settings) + ProjectBlock(project: p, todo: todos[p.id], settings: overlays.projectTab == .settings, filter: $filter, filterFocus: filterFocus) .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 16) @@ -76,6 +78,12 @@ struct ProjectView: View { load(p.id) } } + .onChange(of: overlays.findTask) { + if store.focusedProject() != nil { + overlays.projectTab = .tasks + filterFocus += 1 + } + } } private func refresh() { @@ -152,11 +160,14 @@ private struct ProjectBlock: View { let project: Project let todo: Result? let settings: Bool + @Binding var filter: String + let filterFocus: Int @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays var body: some View { let active = (store.tasks[project.id] ?? []).filter { $0.status == .active } + let items = (try? todo?.get())?.items ?? [] VStack(alignment: .leading, spacing: 0) { if settings { heading("Settings") @@ -193,8 +204,18 @@ private struct ProjectBlock: View { .padding(.vertical, 6) } TaskRows(active: active) - heading("TODO") - TodoBlock(project: project, todo: todo, active: active) + HStack(alignment: .lastTextBaseline, spacing: 12) { + heading("TODO") + Spacer(minLength: 0) + if !items.isEmpty { + FindField(placeholder: "Filter TODO items", text: $filter, focusSignal: filterFocus, end: { + filter = "" + }) + .frame(maxWidth: 260) + } + } + TodoBlock(project: project, todo: todo, active: active, filter: filter) + .environment(\.findQuery, filter) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -496,6 +517,7 @@ private struct TodoBlock: View { let project: Project let todo: Result? let active: [ByreTask] + let filter: String var body: some View { switch todo { @@ -504,22 +526,25 @@ private struct TodoBlock: View { case .failure(let e): row(e.message, error: true) case .success(let t): + let items = filter.isEmpty ? t.items : t.items.filter { matches("\($0.section)\n\($0.title)\n\($0.body)", filter) } if !t.exists { row("No TODO.md on \(project.defaultBranch).") } else if t.items.isEmpty { row("TODO.md has no items.") + } else if items.isEmpty { + row("No TODO items match.") } else { VStack(alignment: .leading, spacing: 0) { - ForEach(Array(t.items.enumerated()), id: \.offset) { i, item in - if !item.section.isEmpty && (i == 0 || t.items[i - 1].section != item.section) { - Text(item.section) + ForEach(Array(items.enumerated()), id: \.offset) { i, item in + if !item.section.isEmpty && (i == 0 || items[i - 1].section != item.section) { + Text(highlighted(item.section, filter)) .font(Fonts.font(12, weight: 700)) .foregroundStyle(Palette.muted) .padding(.top, i == 0 ? 10 : 2) .padding(.bottom, 4) } TodoRow(project: project, item: item, task: active.first { $0.todoItem == item.title }) - .padding(.bottom, i == t.items.count - 1 || t.items[i + 1].section != item.section ? 8 : 0) + .padding(.bottom, i == items.count - 1 || items[i + 1].section != item.section ? 8 : 0) } } } @@ -540,6 +565,7 @@ private struct TodoRow: View { let task: ByreTask? @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays + @Environment(\.findQuery) private var query var body: some View { VStack(alignment: .leading, spacing: 4) { @@ -558,7 +584,7 @@ private struct TodoRow: View { @ViewBuilder private var title: some View { - let text = Text(item.title) + let text = Text(highlighted(item.title, query)) .foregroundStyle(task.map { taskPalette[$0.color] } ?? Palette.text) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) diff --git a/mac/Sources/Byre/Switcher.swift b/mac/Sources/Byre/Switcher.swift index 4455e2a..573e17d 100644 --- a/mac/Sources/Byre/Switcher.swift +++ b/mac/Sources/Byre/Switcher.swift @@ -3,7 +3,7 @@ import SwiftUI typealias WorkflowSummary = Components.Schemas.WorkflowSummary -let goKeys: Set = ["k", "n", "t", "p", "l", "s", "/"] +let goKeys: Set = ["k", "n", "t", "p", "l", "s", "f", "/"] @MainActor func runGo(_ key: String, _ store: Store, _ overlays: Overlays) { @@ -44,6 +44,13 @@ func runGo(_ key: String, _ store: Store, _ overlays: Overlays) { } await confirmMerge(taskId, store, overlays) } + case "f": + overlays.switcher?.close() + if store.paneFocus.inTaskPane { + overlays.findTask += 1 + } else if overlays.leftTab == .lead { + overlays.findLead += 1 + } case "/": overlays.showAlert(ShortcutsHelp()) default: @@ -83,6 +90,7 @@ private let shortcuts: [(String, [(String, String)])] = [ ("⌘P", "Show the current project's dashboard"), ("⌘L", "Open the Lead tab and its composer, closing any view opened over a pane"), ("⌘S", "Merge the right pane's task, or discard it when the agent suggests discarding or there is nothing to merge"), + ("⌘F", "Find in the transcript of the pane you're in, or filter the TODO items on a project's page"), ("⌘[ ⌘]", "Bring the previous or next server's window forward"), ("⌘/", "Show these shortcuts"), ("Esc", "Close the switcher, a dialog, or a view opened over a pane"),