feat: update "jump to" dialog to use better search results, and separate renderer for "quick search" and "jump to"

- Add shared search result renderer for consistent snippet display
- Display content and attribute snippets in Quick Search and Jump To
dialog
- Fix crash when encountering orphaned notes without paths
- Add minimal CSS for snippet highlighting

This provides a better search experience by showing snippets of matching
content directly in search results, helping users identify the right
note
without opening it.

fix: Always show content snippets in search results for better context

- Modified extractContentSnippet to always return a snippet from the
beginning of the note when search tokens don't match the content
- Modified extractAttributeSnippet to show first few attributes when no
match is found
- This ensures Jump To dialog and Quick Search always provide context
via content snippets, even when searching by tags/attributes
- Helps users identify the right note regardless of what matched
(content, tags, or attributes)

asdf
This commit is contained in:
perf3ct
2025-08-19 21:32:08 +00:00
parent c7dd271516
commit 843be0da22
6 changed files with 191 additions and 36 deletions

View File

@@ -30,6 +30,10 @@ export interface Suggestion {
notePathTitle?: string;
notePath?: string;
highlightedNotePathTitle?: string;
contentSnippet?: string;
highlightedContentSnippet?: string;
attributeSnippet?: string;
highlightedAttributeSnippet?: string;
action?: string | "create-note" | "search-notes" | "external-link" | "command";
parentNoteId?: string;
icon?: string;
@@ -308,11 +312,12 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
displayKey: "notePathTitle",
templates: {
suggestion: (suggestion) => {
// Handle different suggestion types
if (suggestion.action === "command") {
let html = `<div class="command-suggestion">`;
html += `<span class="command-icon ${suggestion.icon || "bx bx-terminal"}"></span>`;
html += `<div class="command-content">`;
html += `<div class="command-name">${suggestion.highlightedNotePathTitle}</div>`;
html += `<div class="command-name">${suggestion.highlightedNotePathTitle || suggestion.noteTitle || ''}</div>`;
if (suggestion.commandDescription) {
html += `<div class="command-description">${suggestion.commandDescription}</div>`;
}
@@ -323,7 +328,25 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
html += '</div>';
return html;
}
return `<span class="${suggestion.icon ?? "bx bx-note"}"></span> ${suggestion.highlightedNotePathTitle}`;
// For note suggestions, match Quick Search structure exactly
// Title row with icon
let html = `<div style="display: flex; align-items: center; gap: 6px;">`;
html += `<span class="${suggestion.icon ?? "bx bx-note"}" style="flex-shrink: 0;"></span>`;
html += `<span class="search-result-title" style="flex: 1;">${suggestion.highlightedNotePathTitle || ''}</span>`;
html += `</div>`;
// Add attribute snippet if available
if (suggestion.highlightedAttributeSnippet && suggestion.highlightedAttributeSnippet.trim()) {
html += `<div class="search-result-attributes" style="margin-left: 20px; margin-top: 2px;">${suggestion.highlightedAttributeSnippet}</div>`;
}
// Add content snippet if available
if (suggestion.highlightedContentSnippet && suggestion.highlightedContentSnippet.trim()) {
html += `<div class="search-result-content" style="margin-left: 20px; margin-top: 2px;">${suggestion.highlightedContentSnippet}</div>`;
}
return html;
}
},
// we can't cache identical searches because notes can be created / renamed, new recent notes can be added

View File

@@ -0,0 +1,67 @@
/**
* Quick Search specific result renderer
*
* This module provides HTML rendering functionality specifically for the Quick Search widget.
* The Jump To dialog (note_autocomplete) intentionally has its own inline rendering logic
* with different styling and layout requirements.
*
* SECURITY NOTE: HTML Snippet Handling
* The highlighted snippet fields (highlightedContentSnippet, highlightedAttributeSnippet) contain
* pre-sanitized HTML from the server. The server-side processing:
* 1. Escapes all HTML using the escape-html library
* 2. Adds safe HTML tags for display: <b> for search term highlighting, <br> for line breaks
* 3. See apps/server/src/services/search/services/search.ts for implementation
*
* This means the HTML snippets can be safely inserted without additional escaping on the client side.
*/
import type { Suggestion } from "./note_autocomplete.js";
/**
* Creates HTML for a Quick Search result item
*
* @param result - The search result item to render
* @returns HTML string formatted for Quick Search widget display
*/
export function createSearchResultHtml(result: Suggestion): string {
// Handle command action
if (result.action === "command") {
let html = `<div class="command-suggestion">`;
html += `<span class="command-icon ${result.icon || "bx bx-terminal"}"></span>`;
html += `<div class="command-content">`;
html += `<div class="command-name">${result.highlightedNotePathTitle || ''}</div>`;
if (result.commandDescription) {
html += `<div class="command-description">${result.commandDescription}</div>`;
}
html += `</div>`;
if (result.commandShortcut) {
html += `<kbd class="command-shortcut">${result.commandShortcut}</kbd>`;
}
html += '</div>';
return html;
}
// Default: render as note result with snippets
// Wrap everything in a flex column container
let itemHtml = `<div style="display: flex; flex-direction: column; gap: 2px;">`;
// Title row with icon
itemHtml += `<div style="display: flex; align-items: center; gap: 6px;">`;
itemHtml += `<span class="${result.icon || 'bx bx-note'}" style="flex-shrink: 0;"></span>`;
itemHtml += `<span class="search-result-title" style="flex: 1;">${result.highlightedNotePathTitle || result.notePathTitle || ''}</span>`;
itemHtml += `</div>`;
// Add attribute snippet if available
if (result.highlightedAttributeSnippet && result.highlightedAttributeSnippet.trim()) {
itemHtml += `<div class="search-result-attributes" style="margin-left: 20px;">${result.highlightedAttributeSnippet}</div>`;
}
// Add content snippet if available
if (result.highlightedContentSnippet && result.highlightedContentSnippet.trim()) {
itemHtml += `<div class="search-result-content" style="margin-left: 20px;">${result.highlightedContentSnippet}</div>`;
}
itemHtml += `</div>`;
return itemHtml;
}

View File

@@ -831,8 +831,25 @@ table.promoted-attributes-in-tooltip th {
.aa-dropdown-menu .aa-suggestion {
cursor: pointer;
padding: 5px;
padding: 12px 16px;
margin: 0;
line-height: 1.4;
position: relative;
white-space: normal;
}
/* Add separator between Jump To suggestions like Quick Search */
.jump-to-note-results .aa-suggestion:not(:last-child)::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 80%;
height: 2px;
background: var(--main-border-color);
border-radius: 1px;
opacity: 0.4;
}
.aa-dropdown-menu .aa-suggestion p {
@@ -1781,7 +1798,7 @@ textarea {
}
.jump-to-note-results .aa-suggestions {
padding: 1rem;
padding: 0;
}
/* Command palette styling */
@@ -2255,13 +2272,43 @@ footer.webview-footer button {
padding: 1px 10px 1px 10px;
}
/* Search result highlighting */
/* Search result highlighting - applies to both Quick Search and Jump To */
.search-result-title b,
.search-result-content b {
.search-result-content b,
.search-result-attributes b,
.quick-search .search-result-title b,
.quick-search .search-result-content b,
.quick-search .search-result-attributes b {
font-weight: 900;
color: var(--admonition-warning-accent-color);
}
/* Quick Search specific snippet styling */
.quick-search .search-result-content {
font-size: 0.85em;
color: var(--main-text-color);
opacity: 0.7;
}
.quick-search .search-result-attributes {
font-size: 0.75em;
color: var(--muted-text-color);
opacity: 0.5;
}
/* Jump To (autocomplete) specific snippet styling */
.aa-dropdown-menu .search-result-content {
font-size: 0.82em;
color: var(--main-text-color);
opacity: 0.6;
}
.aa-dropdown-menu .search-result-attributes {
font-size: 0.75em;
color: var(--muted-text-color);
opacity: 0.5;
}
/* Customized icons */
.bx-tn-toc::before {

View File

@@ -530,17 +530,16 @@ body.mobile .dropdown-menu .dropdown-item.submenu-open .dropdown-toggle::after {
}
/* List item */
.jump-to-note-dialog .aa-suggestions div,
.note-detail-empty .aa-suggestions div {
.jump-to-note-dialog .aa-suggestions .aa-suggestion,
.note-detail-empty .aa-suggestions .aa-suggestion {
border-radius: 6px;
padding: 6px 12px;
color: var(--menu-text-color);
cursor: default;
}
/* Selected list item */
.jump-to-note-dialog .aa-suggestions div.aa-cursor,
.note-detail-empty .aa-suggestions div.aa-cursor {
.jump-to-note-dialog .aa-suggestions .aa-suggestion.aa-cursor,
.note-detail-empty .aa-suggestions .aa-suggestion.aa-cursor {
background: var(--hover-item-background-color);
color: var(--hover-item-text-color);
}

View File

@@ -7,6 +7,7 @@ import appContext from "../components/app_context.js";
import shortcutService from "../services/shortcuts.js";
import { t } from "../services/i18n.js";
import { Dropdown, Tooltip } from "bootstrap";
import { createSearchResultHtml } from "../services/quick_search_renderer.js";
const TPL = /*html*/`
<div class="quick-search input-group input-group-sm">
@@ -236,24 +237,14 @@ export default class QuickSearchWidget extends BasicWidget {
const $item = $('<a class="dropdown-item" tabindex="0" href="javascript:">');
// Build the display HTML with content snippet below the title
let itemHtml = `<div style="display: flex; flex-direction: column;">
<div style="display: flex; align-items: flex-start; gap: 6px;">
<span class="${result.icon}" style="flex-shrink: 0; margin-top: 1px;"></span>
<span style="flex: 1;" class="search-result-title">${result.highlightedNotePathTitle}</span>
</div>`;
// Add attribute snippet (tags/attributes) below the title if available
if (result.highlightedAttributeSnippet) {
itemHtml += `<div style="font-size: 0.75em; color: var(--muted-text-color); opacity: 0.5; margin-left: 20px; margin-top: 2px; line-height: 1.2;" class="search-result-attributes">${result.highlightedAttributeSnippet}</div>`;
}
// Add content snippet below the attributes if available
if (result.highlightedContentSnippet) {
itemHtml += `<div style="font-size: 0.85em; color: var(--main-text-color); opacity: 0.7; margin-left: 20px; margin-top: 4px; line-height: 1.3;" class="search-result-content">${result.highlightedContentSnippet}</div>`;
}
itemHtml += `</div>`;
// Use the shared renderer for consistent display
const itemHtml = createSearchResultHtml({
icon: result.icon,
notePathTitle: result.notePathTitle,
highlightedNotePathTitle: result.highlightedNotePathTitle,
highlightedAttributeSnippet: result.highlightedAttributeSnippet,
highlightedContentSnippet: result.highlightedContentSnippet
});
$item.html(itemHtml);

View File

@@ -285,15 +285,19 @@ function performSearch(expression: Expression, searchContext: SearchContext, ena
const noteSet = expression.execute(allNoteSet, executionContext, searchContext);
const searchResults = noteSet.notes.map((note) => {
const notePathArray = executionContext.noteIdToNotePath[note.noteId] || note.getBestNotePath();
const searchResults = noteSet.notes
.map((note) => {
const notePathArray = executionContext.noteIdToNotePath[note.noteId] || note.getBestNotePath();
if (!notePathArray) {
throw new Error(`Can't find note path for note ${JSON.stringify(note.getPojo())}`);
}
if (!notePathArray) {
// Log the orphaned note but don't throw - just skip it
log.info(`Skipping orphaned note without path: ${note.noteId} "${note.title}"`);
return null;
}
return new SearchResult(notePathArray);
});
return new SearchResult(notePathArray);
})
.filter(result => result !== null) as SearchResult[];
for (const res of searchResults) {
res.computeScore(searchContext.fulltextQuery, searchContext.highlightedTokens, enableFuzzyMatching);
@@ -497,6 +501,12 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength
}
}
// If no match found in content, always show the beginning of the note
// This ensures users always get context even when searching by tags/attributes
if (!matchFound) {
snippetStart = 0;
}
// Extract snippet
let snippet = content.substring(snippetStart, snippetStart + maxLength);
@@ -575,6 +585,24 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng
}
}
// If no matching attributes found but we have attributes, show the first few
// This provides context even when searching didn't match attributes
if (matchingAttributes.length === 0 && attributes.length > 0) {
// Filter out internal attributes if not searching for them
const visibleAttributes = attributes.filter(attr =>
!attr.name?.startsWith("internal") &&
!attr.name?.startsWith("dateCreated") &&
!attr.name?.startsWith("dateModified")
);
// Take up to 4 visible attributes
matchingAttributes = visibleAttributes.slice(0, 4).map(attr => ({
name: attr.name || "",
value: attr.value || "",
type: attr.type || ""
}));
}
if (matchingAttributes.length === 0) {
return "";
}