Add ⌘F find in transcripts and a TODO filter on the project page
This commit is contained in:
@@ -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()
|
||||
|
||||
+101
-15
@@ -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<Int> = []
|
||||
@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<Summary: View, Content: View>: 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<Summary: View, Content: View>: 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<Summary: View, Content: View>: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if open {
|
||||
if open || reveal {
|
||||
content
|
||||
}
|
||||
}
|
||||
@@ -1216,6 +1291,11 @@ struct DetailsBox<Summary: View, Content: View>: 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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -125,6 +125,10 @@ final class PaneFocus {
|
||||
#endif
|
||||
}
|
||||
|
||||
var inTaskPane: Bool {
|
||||
inside
|
||||
}
|
||||
|
||||
func setFrame(_ frame: CGRect) {
|
||||
paneFrame = frame
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ struct ProjectView: View {
|
||||
@Environment(Overlays.self) private var overlays
|
||||
@ViewState private var todos: [String: Result<Todo, ApiError>] = [:]
|
||||
@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<Todo, ApiError>?
|
||||
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<Todo, ApiError>?
|
||||
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)
|
||||
|
||||
@@ -3,7 +3,7 @@ import SwiftUI
|
||||
|
||||
typealias WorkflowSummary = Components.Schemas.WorkflowSummary
|
||||
|
||||
let goKeys: Set<String> = ["k", "n", "t", "p", "l", "s", "/"]
|
||||
let goKeys: Set<String> = ["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"),
|
||||
|
||||
Reference in New Issue
Block a user