1use std::collections::HashSet;
14
15use blockworx_canvas2d::css_color;
16use blockworx_doc::id::BlockId;
17use blockworx_kernel::nav::{self, Filtered, Index};
18use blockworx_kernel::{Event, NavTree};
19use blockworx_paint::theme::{Role, Theme, accent_role, avatar_role};
20use blockworx_store::doc::Viewing;
21use blockworx_store::history::{Query, Row, said, tag_query};
22use blockworx_store::tags::Tagging;
23use blockworx_tools::tool::Action;
24use dioxus::prelude::*;
25
26use crate::shell::Shell;
27
28const INDENT: f32 = 16.0;
30
31const SUGGESTIONS: usize = 8;
34
35pub const ROW: &str = "flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left \
37 transition hover:bg-zinc-950/5 dark:hover:bg-white/5";
38
39pub const CURRENT: &str = "flex w-full items-center gap-3 rounded-xl bg-sky-500/12 px-3 py-2 \
41 text-left transition dark:bg-sky-400/15";
42
43pub const FIELD: &str = "w-full rounded-xl bg-zinc-950/5 px-3 py-2 text-sm outline-none \
44 placeholder:text-zinc-400 focus:outline-2 focus:outline-sky-500 dark:bg-white/5";
45
46pub const MUTED: &str = "text-xs text-zinc-500 dark:text-zinc-400";
47
48#[component]
53pub fn History(shell: Shell) -> Element {
54 let chrome = shell.chrome();
55 let mut search = use_signal(String::new);
56 let rows = chrome.read().history.clone();
57 let lens = chrome.read().top_bar.lens.clone();
58 let query = Query::parse(&search());
59 let shown: Vec<&Row> = rows
60 .iter()
61 .rev()
62 .filter(|row| row.rev != lens.head && query.admits(row))
63 .collect();
64 let counted = if query.narrows() {
65 format!("{} of {}", shown.len(), rows.len())
66 } else {
67 rows.len().to_string()
68 };
69 let raise = use_hook(|| CopyValue::new(shell.clone()));
70 let mut day = None;
71 rsx! {
72 div { class: "flex items-center gap-2 px-1 pb-2",
73 input {
74 class: FIELD,
75 value: "{search}",
76 placeholder: "Search history",
77 "aria-label": "Search history",
78 "data-search": "history",
79 oninput: move |event| search.set(event.value()),
80 }
81 span { class: "{MUTED} flex-none", "{counted}" }
82 }
83 if !query.narrows() {
86 button {
87 class: if lens.viewing == Viewing::Head { CURRENT } else { ROW },
88 "data-rev": "head",
89 onclick: move |_| {
90 if lens.viewing != Viewing::Head {
91 raise.read().say(Event::Action(Action::ViewHead));
92 }
93 },
94 span { class: "size-[9px] flex-none rounded-full bg-sky-500" }
95 span { class: "min-w-0 flex-1 text-sm", "Current" }
96 span { class: MUTED,
97 if lens.viewing == Viewing::Head { "editing" } else { "return" }
98 }
99 }
100 }
101 if shown.is_empty() {
102 p { class: "{MUTED} px-3 py-2",
103 "Nothing matches. Try tag:, by:, in:, or # for a rev number."
104 }
105 }
106 for row in shown {
107 {
108 let heading = row.day().filter(|at| Some(at) != day.as_ref());
109 if let Some(at) = &heading {
110 day = Some(*at);
111 }
112 let viewing = lens.viewing == Viewing::Past(row.rev);
113 rsx! {
114 if let Some(at) = heading {
115 p { key: "day-{row.rev.get()}", class: "{MUTED} px-3 pb-1 pt-3 font-medium",
116 "{at.label(blockworx_store::history::now())}"
117 }
118 }
119 if viewing {
120 RevCard {
121 key: "rev-{row.rev.get()}",
122 shell: shell.clone(),
123 row: row.clone(),
124 rows: rows.clone(),
125 search,
126 }
127 } else {
128 RevRow {
129 key: "rev-{row.rev.get()}",
130 shell: shell.clone(),
131 row: row.clone(),
132 rows: rows.clone(),
133 search,
134 }
135 }
136 }
137 }
138 }
139 }
140}
141
142#[component]
144fn RevRow(shell: Shell, row: Row, rows: Vec<Row>, search: Signal<String>) -> Element {
145 let theme = shell.theme();
146 let rev = row.rev;
147 let scope = row.scope_names().join(" / ");
148 let tags = row.tags.clone();
149 let inverse = row.is_inverse();
150 let raise = use_hook(|| CopyValue::new(shell));
151 rsx! {
152 div { class: "flex flex-col", "data-row": "{rev.get()}",
153 button {
154 class: ROW,
155 "data-rev": "{rev.get()}",
156 title: "{row.full_when()}",
157 onclick: move |_| raise.read().say(Event::Action(Action::ViewRev(rev))),
158 { avatar(&row, &theme, Disc::Row) }
159 span { class: "min-w-0 flex-1",
160 span {
161 class: if inverse {
162 "block text-sm italic text-zinc-500 dark:text-zinc-400"
163 } else {
164 "block text-sm"
165 },
166 "{said(&row, &rows)}"
167 }
168 if !scope.is_empty() {
169 span {
172 class: "{MUTED} block truncate",
173 dir: "rtl",
174 title: "{scope}",
175 "{scope}"
176 }
177 }
178 }
179 span { class: "flex flex-none flex-col items-end",
180 span { class: MUTED, "{row.time()}" }
181 span { class: MUTED, "#{rev.get()}" }
182 }
183 }
184 if !tags.is_empty() {
185 Chips { tags, search }
186 }
187 }
188 }
189}
190
191#[component]
200fn RevCard(shell: Shell, row: Row, rows: Vec<Row>, search: Signal<String>) -> Element {
201 let theme = shell.theme();
202 let rev = row.rev;
203 let scope = row.scope_names().join(" / ");
204 rsx! {
205 div {
206 class: "flex flex-col gap-2.5 rounded-2xl bg-sky-500/12 p-3 dark:bg-sky-400/15",
207 "data-rev": "{rev.get()}",
208 "aria-current": "true",
209 div { class: "flex items-center gap-2.5",
210 { avatar(&row, &theme, Disc::Card) }
211 span { class: "flex min-w-0 flex-1 flex-col",
212 span { class: "truncate text-sm font-semibold", "{row.author()}" }
213 span { class: "{MUTED} truncate", "{row.full_when()}" }
214 }
215 span { class: "{MUTED} flex-none", "#{rev.get()}" }
216 }
217 div { class: "flex flex-col gap-1",
218 p { class: "text-[15px] leading-5", "{said(&row, &rows)}" }
219 if !scope.is_empty() {
220 p {
223 class: "truncate text-[11px] text-zinc-500 dark:text-zinc-400",
224 dir: "rtl",
225 title: "{scope}",
226 "{scope}"
227 }
228 }
229 }
230 span { class: "h-px bg-zinc-950/10 dark:bg-white/15" }
231 TagEditor { shell, row, rows, search }
232 }
233 }
234}
235
236#[component]
242fn Chips(tags: Vec<String>, search: Signal<String>) -> Element {
243 rsx! {
244 span { class: "flex flex-wrap gap-1 pb-1.5 pl-[50px] pr-3",
245 for tag in tags {
246 button {
247 key: "{tag}",
248 class: "rounded-md bg-zinc-950/6 px-1.5 text-[11px] \
249 text-zinc-600 dark:bg-white/10 dark:text-zinc-300",
250 "data-tag": "{tag}",
251 onclick: {
252 let tag = tag.clone();
253 move |event: dioxus::prelude::Event<MouseData>| {
254 event.stop_propagation();
255 search.set(tag_query(&tag));
256 }
257 },
258 "{tag}"
259 }
260 }
261 }
262 }
263}
264
265#[component]
268fn TagEditor(shell: Shell, row: Row, rows: Vec<Row>, search: Signal<String>) -> Element {
269 let rev = row.rev;
270 let mut draft = use_signal(String::new);
271 let mut asking = use_signal(|| false);
272 let raise = use_hook(|| CopyValue::new(shell));
273 let tag = move |name: String, how: Tagging| {
274 raise
275 .read()
276 .say(Event::Action(Action::TagRev { at: rev, name, how }));
277 };
278 let offered = suggestions(&draft(), &row, &rows);
279 rsx! {
280 div { class: "flex flex-wrap items-center gap-1",
281 for name in row.tags.clone() {
282 span {
283 key: "{name}",
284 class: "flex items-center rounded-md bg-zinc-950/6 pl-1.5 text-[11px] \
285 dark:bg-white/10",
286 button {
287 class: "pr-1",
288 "data-tag": "{name}",
289 onclick: {
290 let name = name.clone();
291 move |_| search.set(tag_query(&name))
292 },
293 "{name}"
294 }
295 button {
296 class: "px-1 text-zinc-500",
297 title: "Remove this tag",
298 "data-untag": "{name}",
299 onclick: {
300 let name = name.clone();
301 move |_| tag(name.clone(), Tagging::Removed)
302 },
303 "\u{00d7}"
304 }
305 }
306 }
307 span { class: "relative",
308 input {
309 class: "w-[82px] rounded-md bg-zinc-950/5 px-1.5 py-0.5 text-[11px] outline-none \
310 focus:outline-2 focus:outline-sky-500 dark:bg-white/5",
311 value: "{draft}",
312 placeholder: "Add tag",
313 "aria-label": "Add tag",
314 "data-addtag": "true",
315 onfocusin: move |_| asking.set(true),
316 onfocusout: move |_| asking.set(false),
317 oninput: move |event| draft.set(event.value()),
318 onkeydown: move |event| {
319 if event.key() == Key::Enter && !draft.peek().trim().is_empty() {
320 tag(draft.replace(String::new()), Tagging::Added);
321 }
322 },
323 }
324 if asking() && !offered.is_empty() {
328 span {
329 class: "absolute left-0 top-full z-50 mt-1 flex max-h-40 min-w-[136px] \
330 flex-col overflow-y-auto rounded-xl bg-white p-1 shadow-lg \
331 ring-1 ring-zinc-950/5 dark:bg-zinc-800 dark:ring-white/10",
332 role: "listbox",
333 "data-suggests": "true",
334 for name in offered {
335 button {
336 key: "suggest-{name}",
337 class: "rounded-lg px-2 py-1 text-left text-[11px] text-zinc-700 \
338 transition hover:bg-zinc-950/6 dark:text-zinc-200 \
339 dark:hover:bg-white/10",
340 role: "option",
341 "data-suggest": "{name}",
342 onmousedown: {
346 let name = name.clone();
347 move |event: dioxus::prelude::Event<MouseData>| {
348 event.prevent_default();
349 draft.set(String::new());
350 tag(name.clone(), Tagging::Added);
351 }
352 },
353 "{name}"
354 }
355 }
356 }
357 }
358 }
359 }
360 }
361}
362
363fn suggestions(draft: &str, row: &Row, rows: &[Row]) -> Vec<String> {
366 let typed = draft.trim().to_lowercase();
367 let mut seen: Vec<String> = Vec::new();
368 for name in rows.iter().flat_map(|at| &at.tags) {
369 if row.tags.contains(name) || seen.contains(name) {
370 continue;
371 }
372 if typed.is_empty() || name.to_lowercase().contains(&typed) {
373 seen.push(name.clone());
374 }
375 }
376 seen.truncate(SUGGESTIONS);
377 seen
378}
379
380fn avatar(row: &Row, theme: &Theme, size: Disc) -> Element {
383 let by = row.author().to_owned();
384 if by.is_empty() {
385 return rsx! {
386 span { class: "{size.class()} flex-none" }
387 };
388 }
389 let disc = css_color(theme.resolve(avatar_role(&by)));
390 let ink = css_color(theme.resolve(Role::AuthorAvatarText));
391 let initials = row.initials();
392 rsx! {
393 span {
394 class: "{size.class()} grid flex-none place-items-center rounded-full font-medium",
395 style: "background:{disc};color:{ink}",
396 title: "{by}",
397 "{initials}"
398 }
399 }
400}
401
402#[derive(Clone, Copy, PartialEq, Eq, Debug)]
405enum Disc {
406 Row,
407 Card,
408}
409
410impl Disc {
411 fn class(self) -> &'static str {
412 match self {
413 Disc::Row => "size-[26px] text-[10px]",
414 Disc::Card => "size-[30px] text-[11px]",
415 }
416 }
417}
418
419#[component]
425pub fn Parts(shell: Shell) -> Element {
426 let chrome = shell.chrome();
427 let mut filter = use_signal(String::new);
428 let mut expanded = use_signal(HashSet::<BlockId>::new);
429 let mut focus = use_signal(|| None::<BlockId>);
430 let tree = chrome.read().nav_tree.clone();
431 let selected = tree.selected.clone();
432 let theme = shell.theme();
433
434 let mut revealed = use_signal(Vec::<BlockId>::new);
438 {
439 let signature: Vec<BlockId> = selected
440 .iter()
441 .copied()
442 .chain(tree.path.segments().iter().copied())
443 .collect();
444 if *revealed.peek() != signature {
445 revealed.set(signature);
446 let index = Index::of(&tree, None);
447 let mut open = expanded.peek().clone();
448 for &at in tree.path.segments() {
449 open.insert(at);
450 }
451 if let Some(target) = selected
452 .first()
453 .copied()
454 .or_else(|| tree.path.segments().last().copied())
455 {
456 if focus.peek().is_some_and(|at| !index.under(at, target)) {
457 focus.set(None);
458 }
459 index.expand_ancestors(blockworx_editor::path::Scope::Root, target, &mut open);
460 }
461 expanded.set(open);
462 }
463 }
464
465 let typed = filter();
466 let (filtered, wanted) = Filtered::of(&typed);
467 let index = Index::of(&tree, wanted);
468 let root = match filtered {
469 Filtered::Yes => blockworx_editor::path::Scope::Root,
471 Filtered::No => nav::rooted_at(&tree, focus()),
472 };
473 let rows = index.rows(&tree, &expanded.read(), root, filtered);
474 let raise = use_hook(|| CopyValue::new(shell.clone()));
475 rsx! {
476 div { class: "px-1 pb-2",
477 input {
478 class: FIELD,
479 value: "{filter}",
480 placeholder: "Search all blocks\u{2026}",
481 "aria-label": "Search all blocks",
482 "data-search": "parts",
483 oninput: move |event| filter.set(event.value()),
484 }
485 }
486 if let Some(at) = focus() {
487 Crumbs { tree: tree.clone(), focus, at }
488 }
489 if rows.is_empty() {
490 p { class: "{MUTED} px-3 py-2",
491 if filtered == Filtered::Yes { "No matches" } else { "No blocks" }
492 }
493 }
494 for row in rows {
495 {
496 let id = row.id;
497 let here = selected.contains(&id);
498 let dot = css_color(
499 theme.resolve(accent_role(row.accent).unwrap_or(Role::AccentDefault)),
500 );
501 let ancestry = row.ancestors.as_ref().map(|at| at.join("/"));
502 rsx! {
503 div {
504 key: "{id}",
505 class: "flex items-center",
506 style: "padding-left:{row.depth as f32 * INDENT}px",
507 button {
508 class: "grid size-4 flex-none place-items-center text-[9px] \
509 text-zinc-500",
510 "data-twisty": "{id}",
511 "aria-expanded": if row.open { "true" } else { "false" },
512 disabled: !row.has_children,
513 onclick: move |_| {
514 let mut open = expanded.peek().clone();
515 if !open.remove(&id) {
516 open.insert(id);
517 }
518 expanded.set(open);
519 },
520 if row.has_children {
521 if row.open { "\u{25bc}" } else { "\u{25b6}" }
522 }
523 }
524 button {
525 class: if here { CURRENT } else { ROW },
526 "data-block": "{id}",
527 "aria-current": if here { "true" } else { "false" },
528 onclick: move |event: dioxus::prelude::Event<MouseData>| {
529 raise.read().say(Event::Action(Action::NavSelect {
530 block: id,
531 extend: event.modifiers().shift(),
532 }));
533 },
534 span {
535 class: "size-[6px] flex-none rounded-full",
536 style: "background:{dot}",
537 }
538 span { class: "min-w-0 flex-1",
539 span { class: "block truncate text-sm", "{row.label}" }
540 if let Some(path) = ancestry {
541 span {
542 class: "{MUTED} block truncate",
543 dir: "rtl",
544 title: "{path}",
545 "{path}"
546 }
547 }
548 }
549 if let Some(leaves) = row.leaves {
550 span { class: MUTED, "{leaves}" }
551 }
552 }
553 if row.has_children {
554 button {
555 class: "{MUTED} flex-none px-1",
556 title: "Show only this",
557 "data-focus": "{id}",
558 onclick: move |_| focus.set(Some(id)),
559 "\u{bb}"
560 }
561 }
562 }
563 }
564 }
565 }
566 }
567}
568
569#[component]
571fn Crumbs(tree: NavTree, focus: Signal<Option<BlockId>>, at: BlockId) -> Element {
572 let index = Index::of(&tree, None);
573 let trail = index.ancestor_ids(blockworx_editor::path::Scope::Root, at);
574 rsx! {
575 nav { class: "flex flex-wrap items-center gap-1 px-3 pb-2 text-xs",
576 button {
577 class: "text-sky-600 dark:text-sky-400",
578 "data-crumb": "root",
579 onclick: move |_| focus.set(None),
580 "Diagram"
581 }
582 for id in trail {
583 span { key: "crumb-{id}", class: MUTED, "/" }
584 button {
585 class: "text-sky-600 dark:text-sky-400",
586 "data-crumb": "{id}",
587 onclick: move |_| focus.set(Some(id)),
588 "{nav::label_of(&tree, id)}"
589 }
590 }
591 span { class: MUTED, "/" }
592 span { class: "font-medium", "{nav::label_of(&tree, at)}" }
593 }
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 fn row(label: &str, tags: &[&str]) -> Row {
602 Row {
603 rev: blockworx_doc::rev::Rev::ZERO,
604 label: label.to_owned(),
605 touched: 0,
606 written: None,
607 tags: tags.iter().map(|at| (*at).to_owned()).collect(),
608 }
609 }
610
611 #[test]
614 fn suggestions_offer_the_logs_vocabulary_minus_this_revs_own() {
615 let rows = vec![
616 row("Drew it", &["review"]),
617 row("Moved it", &["release", "review"]),
618 ];
619 let carried = row("Named it", &["review"]);
620 assert_eq!(suggestions("", &carried, &rows), vec!["release"]);
621 assert_eq!(suggestions("rel", &carried, &rows), vec!["release"]);
622 assert!(suggestions("zzz", &carried, &rows).is_empty());
623 }
624
625 #[test]
628 fn a_name_is_offered_once_however_many_revs_wear_it() {
629 let rows = vec![
630 row("a", &["review"]),
631 row("b", &["review"]),
632 row("c", &["cut"]),
633 ];
634 assert_eq!(
635 suggestions("", &row("d", &[]), &rows),
636 vec!["review", "cut"]
637 );
638 }
639}