From a7a5ece1fbca5c836e15f230eb901c15f01eb9aa Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Tue, 15 Sep 2026 00:31:21 +0200 Subject: [PATCH] Say how many notes a search matched, and why each one is there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NotesView.matched was computed in Rust, crossed the bridge and decided a single boolean before being thrown away. It is now shown, in the search field, where it takes the shortcut hint's place. The excerpt is new. A card's preview was the first lines of the body, so a note matched at line forty came back with nothing explaining why. The back end says where it matched and quotes that line — and quotes nothing for a title match, the card already showing it. Co-Authored-By: Claude Opus 5 --- e2e/pageobjects/canvas.page.ts | 3 + e2e/specs/06-search-and-filters.e2e.ts | 69 +++++- src-tauri/src/notes/model.rs | 8 + src-tauri/src/notes/view.rs | 232 +++++++++++++++++- src/app/core/data/note.mapper.spec.ts | 1 + src/app/core/ipc/bindings.ts | 25 ++ src/app/core/model/note.model.ts | 17 +- .../core/services/i18n/translations/en.json | 1 + .../core/services/i18n/translations/fr.json | 1 + src/app/core/state/notes-query.store.ts | 11 +- src/app/core/state/notes.store.ts | 1 + .../note-card/note-card.component.html | 10 +- .../note-card/note-card.component.scss | 12 + .../note-card/note-card.component.spec.ts | 45 ++++ .../note-card/note-card.component.ts | 29 ++- .../search-box/search-box.component.html | 9 +- .../search-box/search-box.component.scss | 8 + .../search-box/search-box.component.spec.ts | 34 +++ .../header/search-box/search-box.component.ts | 7 + src/app/notes/notes-page.component.html | 1 + src/testing/fake-notes-repository.ts | 2 + src/testing/note.fixture.ts | 1 + 22 files changed, 506 insertions(+), 21 deletions(-) diff --git a/e2e/pageobjects/canvas.page.ts b/e2e/pageobjects/canvas.page.ts index 85b5de0..0f2baf8 100644 --- a/e2e/pageobjects/canvas.page.ts +++ b/e2e/pageobjects/canvas.page.ts @@ -174,6 +174,9 @@ export const canvas = { noResults: () => $(testid('canvas-no-results')), + /** What the search field says instead of its shortcut hint while filtering. */ + matchedCount: () => $(testid('search-matched')).getText(), + async openTagManager(): Promise { await $(testid('tag-manage')).click(); await $(testid('tag-manager-close')).waitForExist({ timeout: 10_000 }); diff --git a/e2e/specs/06-search-and-filters.e2e.ts b/e2e/specs/06-search-and-filters.e2e.ts index acad90c..07abfac 100644 --- a/e2e/specs/06-search-and-filters.e2e.ts +++ b/e2e/specs/06-search-and-filters.e2e.ts @@ -1,4 +1,4 @@ -import { expect } from '@wdio/globals'; +import { $, expect } from '@wdio/globals'; import { canvas } from '../pageobjects/canvas.page.js'; import { cursorOf, reloadCanvas, testid } from '../support/app.js'; @@ -82,6 +82,73 @@ describe('Search, filters and facets', () => { expect(await canvas.noResults().isExisting()).toBe(true); }); + /** + * The count was already computed in Rust and thrown away on arrival; the excerpt is + * new. Both answer the same question — how big is this result, and why is that card + * in it — which a canvas full of first-three-lines previews could not. + */ + describe('what a search says about itself', () => { + /** + * ⚠️ Mocha runs a suite's own tests **before** its nested suites, so this block is + * the last thing in the file whatever its position in it — and one process serves + * the whole run, so a search left in the field is a search `07-checklists` inherits. + * It did, and every one of its scenarios failed on a canvas holding one card. + */ + after(async () => { + await canvas.clearSearch(); + }); + + it('counts the results, and says zero rather than going quiet', async () => { + await canvas.search('Docker'); + // Against what is on screen rather than a number written down here: the corpus is + // shared with every spec file that ran before this one. + expect(await canvas.matchedCount()).toContain(String((await canvas.titles()).length)); + + await canvas.search('nothing matches this'); + expect(await canvas.matchedCount()).toContain('0'); + }); + + it('hides the count again once nothing is being filtered', async () => { + await canvas.clearSearch(); + expect(await $(testid('search-matched')).isExisting()).toBe(false); + }); + + it('quotes the line that matched rather than the head of the body', async () => { + const spaceId = await homeSpaceId(); + await bridge.createNote( + draft({ + spaceId, + title: 'Deployment runbook', + // The needle is on the last line: a preview of the first three explains nothing. + content: ['# preamble', 'nothing to see', 'still nothing', ' helm upgrade gateway'].join( + String.fromCharCode(10), + ), + language: 'sh', + }), + ); + await reloadCanvas(); + + await canvas.search('helm'); + const card = await canvas.cardWithTitle('Deployment runbook'); + // Trimmed of its indentation: a card shows one line and it starts with the code. + expect(await card.$('.card-snippet').getText()).toBe('helm upgrade gateway'); + }); + + it('quotes the tag when that is what matched, since no preview ever showed it', async () => { + await canvas.search('db'); + const card = await canvas.cardWithTitle('Étape de migration'); + expect(await card.$(testid('note-card-hit')).getText()).toContain('db'); + }); + + it('quotes nothing when the title is what matched, the card showing it already', async () => { + await canvas.search('Docker'); + const card = await canvas.cardWithTitle('Docker compose'); + expect(await card.$(testid('note-card-hit')).isExisting()).toBe(false); + // Back to the head of the body, which is what the card shows outside a search. + expect(await card.$('.card-snippet').getText()).toContain('docker compose up -d'); + }); + }); + it('goes back to the chronological sections when the search is cleared', async () => { await canvas.clearSearch(); const keys = await canvas.sectionKeys(); diff --git a/src-tauri/src/notes/model.rs b/src-tauri/src/notes/model.rs index 5f18b0b..78a489e 100644 --- a/src-tauri/src/notes/model.rs +++ b/src-tauri/src/notes/model.rs @@ -10,6 +10,9 @@ use specta::Type; use super::checklist::{self, ChecklistItem, NoteKind}; use super::language::{self, Language}; use super::placeholder::{self, Placeholder}; +// The search vocabulary lives with the matching, in `view`. The two modules name each +// other, which inside one feature is a reference rather than a dependency. +use super::view::SearchHit; #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -204,6 +207,10 @@ pub struct DisplayNote { /// list travels as the Markdown of its items, a snippet as `None`. Decided here /// so `checklist::to_markdown` stays the only place the `- [x] ` syntax exists. pub copy_text: Option, + /// Why this note is in the results, when the card is not already showing it. + /// Filled in afterwards by `view::build`, like the attachment count above it — + /// `None` outside a search, and for a note found by its own title. + pub search_hit: Option, } impl std::ops::Deref for DisplayNote { @@ -220,6 +227,7 @@ pub fn decorate(note: Note, now: DateTime) -> DisplayNote { expiring_soon: expires_soon(¬e, now), placeholders: placeholder::parse(¬e.content, ¬e.placeholder_values), attachment_count: 0, + search_hit: None, copy_text: match note.kind { NoteKind::Checklist => Some(checklist::to_markdown(¬e.items)), NoteKind::Snippet => None, diff --git a/src-tauri/src/notes/view.rs b/src-tauri/src/notes/view.rs index a398dbe..a8b207e 100644 --- a/src-tauri/src/notes/view.rs +++ b/src-tauri/src/notes/view.rs @@ -57,6 +57,29 @@ pub struct NotesView { pub matched: u32, } +/// Which part of a note a search found, when the card is not already showing it. +/// +/// ⚠️ No `Title` variant, deliberately: the title is the biggest thing on a card, so a +/// note found by it needs no explanation and an excerpt would repeat what the reader is +/// looking at. "Matched on the title" is [`SearchMatch::Title`], which carries nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum SearchField { + Tag, + Body, + Item, +} + +/// What made a note match, and where — so a card can show the line that put it in the +/// results rather than its first three, which may have nothing to do with the query. +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SearchHit { + pub field: SearchField, + /// The matching **line**, not the whole body: a card has room for one. + pub excerpt: String, +} + #[derive(Debug, Clone, Serialize, Type)] #[serde(rename_all = "camelCase")] pub struct NoteSection { @@ -106,8 +129,18 @@ pub fn apply_global_defaults(view: &mut NotesView, globals: &BTreeMap, facets: Facets, request: &NotesQuery) -> NotesView { let needle = fold(request.search.trim()); + // Collected while filtering rather than looked for again afterwards: the match has + // just been found, and finding it twice on 800 notes is paid for twice. + let mut hits: HashMap = HashMap::new(); if !needle.is_empty() { - notes.retain(|note| matches_search(note, &needle)); + notes.retain(|note| match find_match(note, &needle) { + None => false, + Some(SearchMatch::Title) => true, + Some(SearchMatch::Elsewhere(hit)) => { + hits.insert(note.id.clone(), hit); + true + } + }); } // A quick filter restricts a view that stays chronological; a search or a @@ -122,7 +155,7 @@ pub fn build(mut notes: Vec, facets: Facets, request: &NotesQuery) -> Note let offset = offset_from_minutes(request.tz_offset_minutes); - NotesView { + let mut view = NotesView { sections: build_sections( notes, is_filtering, @@ -134,6 +167,24 @@ pub fn build(mut notes: Vec, facets: Facets, request: &NotesQuery) -> Note available_languages: facets.languages, is_filtering, matched, + }; + + // A pass of its own, like the attachment counter and the global defaults: the notes + // become `DisplayNote`s inside `build_sections`, and threading a second value + // through it would have cost every section-splitting test an argument. + apply_search_hits(&mut view, &mut hits); + view +} + +fn apply_search_hits(view: &mut NotesView, hits: &mut HashMap) { + if hits.is_empty() { + return; + } + + for section in &mut view.sections { + for note in &mut section.notes { + note.search_hit = hits.remove(¬e.id); + } } } @@ -142,14 +193,56 @@ pub fn build(mut notes: Vec, facets: Facets, request: &NotesQuery) -> Note /// ⚠️ Folded in Rust and not in SQL: without ICU, SQLite's `LOWER()` only handles /// ASCII, so `Étape` would not match `étape`. The items count as much as the /// content — a todo list has no body to be found by. -fn matches_search(note: &Note, needle: &str) -> bool { - contains_folded(¬e.title, needle) - || note.tags.iter().any(|tag| contains_folded(tag, needle)) - || contains_folded(¬e.content, needle) - || note - .items - .iter() - .any(|item| contains_folded(&item.text, needle)) +fn find_match(note: &Note, needle: &str) -> Option { + if contains_folded(¬e.title, needle) { + return Some(SearchMatch::Title); + } + + if let Some(tag) = note.tags.iter().find(|tag| contains_folded(tag, needle)) { + return Some(SearchMatch::elsewhere(SearchField::Tag, tag)); + } + + if let Some(line) = note + .content + .lines() + .find(|line| contains_folded(line, needle)) + { + return Some(SearchMatch::elsewhere(SearchField::Body, line.trim())); + } + + note.items + .iter() + .find(|item| contains_folded(&item.text, needle)) + .map(|item| SearchMatch::elsewhere(SearchField::Item, &item.text)) +} + +/// Answered by [`find_match`]: a note either fails to match, matches on something the +/// card already shows, or matches on something it does not and can quote. +enum SearchMatch { + Title, + Elsewhere(SearchHit), +} + +impl SearchMatch { + fn elsewhere(field: SearchField, text: &str) -> Self { + Self::Elsewhere(SearchHit { + field, + excerpt: clip(text), + }) + } +} + +/// A card shows one line, and a body is free to hold a minified payload on one of them. +/// Without this the whole of it would cross the bridge to be thrown away by `overflow`. +const EXCERPT_CHARS: usize = 160; + +/// Characters and not bytes: `s[..160]` panics in the middle of a `é`. +fn clip(text: &str) -> String { + let mut clipped: String = text.chars().take(EXCERPT_CHARS).collect(); + if text.chars().nth(EXCERPT_CHARS).is_some() { + clipped.push('…'); + } + clipped } /// Lowercase **and** accent-free, so `etape` finds `Étape`. Both sides go through @@ -451,6 +544,12 @@ mod tests { mod search { use super::*; + /// The rule these assert on is "does it match at all", which is what + /// [`find_match`] answers on its way to saying where. + fn matches_search(note: &Note, needle: &str) -> bool { + find_match(note, needle).is_some() + } + #[test] fn the_title_the_tags_and_the_content_are_all_searched() { let note = Note { @@ -546,6 +645,119 @@ mod tests { } } + /// A card's preview is the head of the body, which has nothing to do with the + /// query when the match sits at line forty. What the view answers with is the + /// line that actually matched. + mod hits { + use super::*; + + fn hit_for(note: Note, search: &str) -> Option { + let view = build( + vec![note], + Facets::default(), + &NotesQuery { + search: search.to_string(), + ..request() + }, + ); + + view.sections + .into_iter() + .flat_map(|section| section.notes) + .next() + .and_then(|note| note.search_hit) + } + + #[test] + fn quotes_the_line_that_matched_and_not_the_first_one() { + let note = Note { + content: "first\nsecond\n kubectl rollout restart\nfourth".to_string(), + ..sample() + }; + + let hit = hit_for(note, "rollout").expect("a body match is worth quoting"); + assert_eq!(hit.field, SearchField::Body); + // Trimmed: a card shows one line and it should start with the code. + assert_eq!(hit.excerpt, "kubectl rollout restart"); + } + + #[test] + fn says_nothing_when_the_title_is_what_matched() { + let note = Note { + title: "Rollout".to_string(), + content: "kubectl apply".to_string(), + ..sample() + }; + + // The title is the biggest thing on the card: quoting it back would + // repeat what the reader is already looking at. + assert!(hit_for(note, "rollout").is_none()); + } + + #[test] + fn quotes_the_tag_and_the_item_the_card_does_not_show() { + let tagged = Note { + tags: vec!["urgent".to_string()], + ..sample() + }; + let hit = hit_for(tagged, "urgen").expect("a tag match is worth quoting"); + assert_eq!(hit.field, SearchField::Tag); + assert_eq!(hit.excerpt, "urgent"); + + let listed = Note { + items: vec![ + ChecklistItem { + text: "Bump the version".to_string(), + done: false, + }, + ChecklistItem { + text: "Push the tag".to_string(), + done: false, + }, + ], + ..sample() + }; + let hit = hit_for(listed, "push").expect("an item match is worth quoting"); + assert_eq!(hit.field, SearchField::Item); + assert_eq!(hit.excerpt, "Push the tag"); + } + + #[test] + fn clips_a_line_long_enough_to_be_a_payload() { + let note = Note { + content: format!("{}needle", "x".repeat(400)), + ..sample() + }; + + let hit = hit_for(note, "needle").expect("it still matches"); + // 160 characters and the ellipsis that says there were more. + assert_eq!(hit.excerpt.chars().count(), EXCERPT_CHARS + 1); + assert!(hit.excerpt.ends_with('…')); + } + + #[test] + fn clips_on_characters_rather_than_bytes() { + let note = Note { + content: format!("{}needle", "é".repeat(400)), + ..sample() + }; + + // A byte slice would have panicked in the middle of one of these. + let hit = hit_for(note, "needle").expect("an accented needle still matches"); + assert_eq!(hit.excerpt.chars().count(), EXCERPT_CHARS + 1); + } + + #[test] + fn carries_nothing_when_nothing_is_searched() { + let note = Note { + content: "kubectl apply".to_string(), + ..sample() + }; + + assert!(hit_for(note, " ").is_none()); + } + } + mod sections { use super::*; use crate::notes::model::NoteLifecycle; diff --git a/src/app/core/data/note.mapper.spec.ts b/src/app/core/data/note.mapper.spec.ts index 7df93a6..60f777a 100644 --- a/src/app/core/data/note.mapper.spec.ts +++ b/src/app/core/data/note.mapper.spec.ts @@ -27,6 +27,7 @@ const BASE_DTO: WireNote = { placeholders: [], attachmentCount: 0, copyText: null, + searchHit: null, }; describe('toNote', () => { diff --git a/src/app/core/ipc/bindings.ts b/src/app/core/ipc/bindings.ts index 6d525bd..ac2e304 100644 --- a/src/app/core/ipc/bindings.ts +++ b/src/app/core/ipc/bindings.ts @@ -175,6 +175,12 @@ export type DisplayNote = { * so `checklist::to_markdown` stays the only place the `- [x] ` syntax exists. */ copyText: string | null, + /** + * Why this note is in the results, when the card is not already showing it. + * Filled in afterwards by `view::build`, like the attachment count above it — + * `None` outside a search, and for a note found by its own title. + */ + searchHit: SearchHit | null, } & Note; /** @@ -371,6 +377,25 @@ export type Placeholder = { value: string, }; +/** + * Which part of a note a search found, when the card is not already showing it. + * + * ⚠️ No `Title` variant, deliberately: the title is the biggest thing on a card, so a + * note found by it needs no explanation and an excerpt would repeat what the reader is + * looking at. "Matched on the title" is [`SearchMatch::Title`], which carries nothing. + */ +export type SearchField = "tag" | "body" | "item"; + +/** + * What made a note match, and where — so a card can show the line that put it in the + * results rather than its first three, which may have nothing to do with the query. + */ +export type SearchHit = { + field: SearchField, + /** The matching **line**, not the whole body: a card has room for one. */ + excerpt: string, +}; + /** * Three fields rather than a map: a missing shortcut would be an action no key * reaches any more, and a map would leave the compiler silent about it. diff --git a/src/app/core/model/note.model.ts b/src/app/core/model/note.model.ts index f4848b1..bb6c501 100644 --- a/src/app/core/model/note.model.ts +++ b/src/app/core/model/note.model.ts @@ -1,9 +1,12 @@ -import type { ExportReport, ImportReport } from '@core/ipc/bindings'; +import type { ExportReport, ImportReport, SearchHit } from '@core/ipc/bindings'; import { LanguageTag } from '@core/model/language.model'; import { ChecklistItem, NoteKind } from './checklist.model'; export { type ChecklistItem, type NoteKind } from './checklist.model'; +/** Generated: a variant added in Rust stops the card compiling until it is handled. */ +export type { SearchField, SearchHit } from '@core/ipc/bindings'; + export type NoteLifecycle = { readonly kind: 'permanent' } | { readonly kind: 'expires'; readonly at: Date }; /** @@ -16,8 +19,9 @@ export type NoteFooter = | { readonly kind: 'age'; readonly at: Date }; /** - * `footer`, `expiringSoon`, `placeholders`, `attachmentCount` and `copyText` are - * **derived by the back end and never written** — they are what `DisplayNote` adds. + * `footer`, `expiringSoon`, `placeholders`, `attachmentCount`, `copyText` and + * `searchHit` are **derived by the back end and never written** — they are what + * `DisplayNote` adds. */ export interface Note { readonly id: string; @@ -45,6 +49,12 @@ export interface Note { * todo list's items. `null` for a snippet — see `noteCopyText`. */ readonly copyText: string | null; + /** + * Why this note is in the results, when the card is not already showing it. `null` + * outside a search, and for a note found by its own title — quoting that back would + * repeat the biggest thing on the card. + */ + readonly searchHit: SearchHit | null; } /** The id and the timestamps are assigned by persistence; the rest is derived. */ @@ -58,6 +68,7 @@ export type NoteDraft = Omit< | 'placeholders' | 'attachmentCount' | 'copyText' + | 'searchHit' >; export type NotePatch = Partial; diff --git a/src/app/core/services/i18n/translations/en.json b/src/app/core/services/i18n/translations/en.json index cdacf34..a531da5 100644 --- a/src/app/core/services/i18n/translations/en.json +++ b/src/app/core/services/i18n/translations/en.json @@ -29,6 +29,7 @@ "pinnedState": "Pinned note", "loading": "Loading notes…", "noResults": "No note matches this search.", + "searchMatched": "{{count}} result(s)", "attachmentsCount": "{{count}} attachment(s)", "placeholdersCount": "{{count}} field(s) to fill", "selectNote": "Select note {{title}}", diff --git a/src/app/core/services/i18n/translations/fr.json b/src/app/core/services/i18n/translations/fr.json index bbbeb7b..4b74110 100644 --- a/src/app/core/services/i18n/translations/fr.json +++ b/src/app/core/services/i18n/translations/fr.json @@ -29,6 +29,7 @@ "pinnedState": "Note épinglée", "loading": "Chargement des notes…", "noResults": "Aucune note ne correspond à cette recherche.", + "searchMatched": "{{count}} résultat(s)", "attachmentsCount": "{{count}} pièce(s) jointe(s)", "placeholdersCount": "{{count}} champ(s) à remplir", "selectNote": "Sélectionner la note {{title}}", diff --git a/src/app/core/state/notes-query.store.ts b/src/app/core/state/notes-query.store.ts index 2d223b5..2e805fc 100644 --- a/src/app/core/state/notes-query.store.ts +++ b/src/app/core/state/notes-query.store.ts @@ -138,11 +138,18 @@ export class NotesQueryStore { readonly allLanguages = computed(() => this.view()?.availableLanguages ?? []); readonly isFiltering = computed(() => this.view()?.isFiltering ?? false); - readonly hasNoResults = computed(() => { + /** + * How many notes the query matched, `null` when nothing is being filtered. Counted in + * Rust and crossing the bridge since `NotesView` existed — it decided a boolean and + * was thrown away, which is why a search said nothing about its own size. + */ + readonly matched = computed(() => { const view = this.view(); - return view !== null && view.isFiltering && view.matched === 0; + return view !== null && view.isFiltering ? view.matched : null; }); + readonly hasNoResults = computed(() => this.matched() === 0); + /** * ⚠️ `view()` is read **before** the resource state: an `&&` the other way round * would short-circuit past the read, dropping the freshly loaded view. diff --git a/src/app/core/state/notes.store.ts b/src/app/core/state/notes.store.ts index 96afefb..80f96fd 100644 --- a/src/app/core/state/notes.store.ts +++ b/src/app/core/state/notes.store.ts @@ -70,6 +70,7 @@ function emptyNote(spaceId: string, now: Date, kind: NoteKind): Note { placeholders: [], attachmentCount: 0, copyText: null, + searchHit: null, }; } diff --git a/src/app/notes/canvas/note-section/note-card/note-card.component.html b/src/app/notes/canvas/note-section/note-card/note-card.component.html index 948d254..ea1d156 100644 --- a/src/app/notes/canvas/note-section/note-card/note-card.component.html +++ b/src/app/notes/canvas/note-section/note-card/note-card.component.html @@ -72,7 +72,7 @@ - } @else { + } @else if (snippetIsCode()) { + } @else { + + + @if (searchHit()?.field === 'tag') { + + } + {{ snippet() }} + } diff --git a/src/app/notes/canvas/note-section/note-card/note-card.component.scss b/src/app/notes/canvas/note-section/note-card/note-card.component.scss index 839efcc..c89a075 100644 --- a/src/app/notes/canvas/note-section/note-card/note-card.component.scss +++ b/src/app/notes/canvas/note-section/note-card/note-card.component.scss @@ -110,6 +110,18 @@ app-copy-button:focus-within { overflow: hidden; } +// The line a search found, when it is not code: a tag or a checklist item. Prose rather +// than a monospaced block, since that is what it is. +.card-hit { + display: block; + font-family: inherit; + color: var(--text-1); +} + +.hit-mark { + color: var(--text-2); +} + .card-footer { display: flex; align-items: center; diff --git a/src/app/notes/canvas/note-section/note-card/note-card.component.spec.ts b/src/app/notes/canvas/note-section/note-card/note-card.component.spec.ts index 1d75bc4..2f402ff 100644 --- a/src/app/notes/canvas/note-section/note-card/note-card.component.spec.ts +++ b/src/app/notes/canvas/note-section/note-card/note-card.component.spec.ts @@ -89,6 +89,51 @@ describe('NoteCardComponent', () => { expect(lines.map((line) => line.nativeElement.textContent)).toEqual(['one', 'two', 'three', 'four']); }); + /** + * A card's preview is the head of the body, which explains nothing when the match sits + * at line forty — or in a tag, which the preview never showed at all. + */ + describe('what a search put it here for', () => { + it('shows the matching line instead of the head of the body', async () => { + fixture.componentRef.setInput( + 'note', + createNote({ + content: 'one\ntwo\nthree\nfour', + searchHit: { field: 'body', excerpt: 'kubectl rollout restart' }, + }), + ); + await fixture.whenStable(); + + const lines = fixture.debugElement.queryAll(By.css('.card-snippet .line-content')); + expect(lines.map((line) => line.nativeElement.textContent)).toEqual(['kubectl rollout restart']); + }); + + it('renders a tag or an item as prose, which is what they are', async () => { + fixture.componentRef.setInput('note', createNote({ searchHit: { field: 'tag', excerpt: 'urgent' } })); + await fixture.whenStable(); + + // Not through the highlighter: it would paint the words of a tag as keywords. + expect(fixture.debugElement.query(By.css('.card-snippet .line-content'))).toBeNull(); + expect(text('[data-testid="note-card-hit"]')).toBe('# urgent'); + + fixture.componentRef.setInput( + 'note', + createNote({ searchHit: { field: 'item', excerpt: 'Push the tag' } }), + ); + await fixture.whenStable(); + + expect(text('[data-testid="note-card-hit"]')).toBe('Push the tag'); + }); + + it('keeps the head of the body when the back end quoted nothing', async () => { + fixture.componentRef.setInput('note', createNote({ content: 'one\ntwo', searchHit: null })); + await fixture.whenStable(); + + const lines = fixture.debugElement.queryAll(By.css('.card-snippet .line-content')); + expect(lines.map((line) => line.nativeElement.textContent)).toEqual(['one', 'two']); + }); + }); + it('colours the snippet according to the note language', async () => { fixture.componentRef.setInput('note', createNote({ language: 'json', content: '{"a": 1}' })); await fixture.whenStable(); diff --git a/src/app/notes/canvas/note-section/note-card/note-card.component.ts b/src/app/notes/canvas/note-section/note-card/note-card.component.ts index 3697c6e..7c16ea2 100644 --- a/src/app/notes/canvas/note-section/note-card/note-card.component.ts +++ b/src/app/notes/canvas/note-section/note-card/note-card.component.ts @@ -87,9 +87,32 @@ export class NoteCardComponent { }); } - protected readonly snippet = computed(() => - this.note().content.split('\n').slice(0, SNIPPET_LINES).join('\n'), - ); + /** + * What the card shows in place of a body when a search put it here: the line that + * actually matched. + * + * ⚠️ The back end decides, as everywhere else — `null` outside a search **and** for a + * note found by its own title, which the card already shows in full. Without it the + * preview was the first three lines of the body, so a note matched at line forty came + * back with nothing explaining why it was in the list. + */ + protected readonly searchHit = computed(() => this.note().searchHit); + + protected readonly snippet = computed(() => { + const hit = this.searchHit(); + if (hit) return hit.excerpt; + + return this.note().content.split('\n').slice(0, SNIPPET_LINES).join('\n'); + }); + + /** + * A body excerpt is still code and stays coloured; a tag or a checklist item is not, + * and the highlighter would paint its words as keywords. + */ + protected readonly snippetIsCode = computed(() => { + const hit = this.searchHit(); + return !hit || hit.field === 'body'; + }); protected readonly displayedTags = computed(() => this.note().tags.slice(0, MAX_VISIBLE_TAGS)); diff --git a/src/app/notes/header/search-box/search-box.component.html b/src/app/notes/header/search-box/search-box.component.html index a248e61..815015e 100644 --- a/src/app/notes/header/search-box/search-box.component.html +++ b/src/app/notes/header/search-box/search-box.component.html @@ -11,5 +11,12 @@ [value]="query()" (input)="onInput(searchInput.value)" /> - + + @if (matched() !== null) { + {{ + 'notes.searchMatched' | transloco: { count: matched() } + }} + } @else { + + } diff --git a/src/app/notes/header/search-box/search-box.component.scss b/src/app/notes/header/search-box/search-box.component.scss index f6343b7..d2d94c4 100644 --- a/src/app/notes/header/search-box/search-box.component.scss +++ b/src/app/notes/header/search-box/search-box.component.scss @@ -37,3 +37,11 @@ color: var(--text-1); margin-left: auto; } + +// Takes the shortcut hint's place, and its alignment with it. +.count { + margin-left: auto; + white-space: nowrap; + font-size: 11px; + color: var(--text-2); +} diff --git a/src/app/notes/header/search-box/search-box.component.spec.ts b/src/app/notes/header/search-box/search-box.component.spec.ts index fc7ad6d..9200fcb 100644 --- a/src/app/notes/header/search-box/search-box.component.spec.ts +++ b/src/app/notes/header/search-box/search-box.component.spec.ts @@ -55,6 +55,40 @@ describe('SearchBoxComponent', () => { expect(decorations.every((span: Element) => span.getAttribute('aria-hidden') === 'true')).toBe(true); }); + /** + * The count was computed in Rust, crossed the bridge and was thrown away on arrival — + * `NotesView.matched` decided one boolean and was never shown. + */ + describe('the count', () => { + it('replaces the shortcut hint while something is being filtered', async () => { + fixture.componentRef.setInput('matched', 12); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('[data-testid="search-matched"]').textContent.trim()).toBe( + '12 résultat(s)', + ); + expect(fixture.nativeElement.querySelector('.kbd')).toBeNull(); + }); + + it('says zero rather than falling back to the hint', async () => { + // The answer that matters most, and the one a truthiness check would swallow. + fixture.componentRef.setInput('matched', 0); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('[data-testid="search-matched"]').textContent.trim()).toBe( + '0 résultat(s)', + ); + }); + + it('shows the hint again when nothing is being filtered', async () => { + fixture.componentRef.setInput('matched', null); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('[data-testid="search-matched"]')).toBeNull(); + expect(fixture.nativeElement.querySelector('.kbd')).not.toBeNull(); + }); + }); + it('focuses the input on Ctrl/Cmd+K and prevents the browser default', () => { const event = pressShortcut(); diff --git a/src/app/notes/header/search-box/search-box.component.ts b/src/app/notes/header/search-box/search-box.component.ts index b294ced..071e9af 100644 --- a/src/app/notes/header/search-box/search-box.component.ts +++ b/src/app/notes/header/search-box/search-box.component.ts @@ -25,6 +25,13 @@ export class SearchBoxComponent { */ readonly shortcutEnabled = input(true); + /** + * How many notes the query matched, `null` when nothing is being filtered. It takes the + * shortcut hint's place rather than a slot of its own: the hint is what you need before + * you search, the count is what you need once you have. + */ + readonly matched = input(null); + protected readonly shortcutHint = platformShortcutHint(); private readonly inputRef = viewChild.required>('searchInput'); diff --git a/src/app/notes/notes-page.component.html b/src/app/notes/notes-page.component.html index c1bb61b..eea1f7e 100644 --- a/src/app/notes/notes-page.component.html +++ b/src/app/notes/notes-page.component.html @@ -10,6 +10,7 @@ diff --git a/src/testing/fake-notes-repository.ts b/src/testing/fake-notes-repository.ts index eae7bff..c73b589 100644 --- a/src/testing/fake-notes-repository.ts +++ b/src/testing/fake-notes-repository.ts @@ -90,6 +90,7 @@ export class FakeNotesRepository implements Pick = {}): Note { placeholders: [], attachmentCount: 0, copyText: null, + searchHit: null, ...overrides, };