chore: update Angular TypeScript build info cache

This commit is contained in:
2025-10-02 10:55:11 -04:00
parent 1d4da38164
commit 20cc6e9215
59 changed files with 8614 additions and 496 deletions
@@ -1,6 +1,9 @@
import { Component, ChangeDetectionStrategy, output, signal } from '@angular/core';
import { Component, ChangeDetectionStrategy, output, signal, effect, input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { SearchInputWithAssistantComponent } from '../search-input-with-assistant/search-input-with-assistant.component';
import { GraphSettingsService } from '../../app/graph/graph-settings.service';
import { GraphConfig, GraphColorGroup, createGraphColor, intToHex } from '../../app/graph/graph-settings.types';
export interface GraphFilters {
showTags: boolean;
@@ -32,7 +35,7 @@ export interface GraphOptions {
@Component({
selector: 'app-graph-options-panel',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, SearchInputWithAssistantComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="graph-options-panel bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 h-full overflow-y-auto">
@@ -86,12 +89,15 @@ export interface GraphOptions {
</div>
<div class="mt-3">
<input
type="text"
[(ngModel)]="filters().searchQuery"
(ngModelChange)="emitOptionsChange()"
placeholder="🔍 Search files…"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
<app-search-input-with-assistant
[value]="searchQuery()"
(valueChange)="onSearchChange($event)"
(submit)="onSearchSubmit($event)"
[placeholder]="'Search files…'"
[context]="'graph'"
[showSearchIcon]="true"
[inputClass]="'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500'"
/>
</div>
</section>
@@ -104,8 +110,36 @@ export interface GraphOptions {
Groups
</h3>
<div class="space-y-2 mb-3">
@for (group of colorGroups(); track $index) {
<div class="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div
[style.background-color]="getGroupColorPreview(group)"
class="w-6 h-6 rounded-full border-2 border-white dark:border-gray-800 shadow-sm flex-shrink-0">
</div>
<input
type="text"
[value]="group.query"
(input)="updateGroupQuery($index, $any($event.target).value)"
placeholder="Query (e.g., tag:#code)"
class="flex-1 px-2 py-1 text-xs bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded"
/>
<button
(click)="removeGroup($index)"
class="p-1 text-gray-400 hover:text-red-500 transition-colors"
title="Remove group"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
}
</div>
<button
type="button"
(click)="addNewGroup()"
class="w-full px-3 py-2 text-sm bg-purple-600 hover:bg-purple-700 text-white rounded-md transition-colors">
New group
</button>
@@ -291,9 +325,9 @@ export interface GraphOptions {
})
export class GraphOptionsPanelComponent {
filters = signal<GraphFilters>({
showTags: true,
showAttachments: true,
existingFilesOnly: true,
showTags: false,
showAttachments: false,
existingFilesOnly: false,
showOrphans: true,
searchQuery: ''
});
@@ -311,16 +345,89 @@ export class GraphOptionsPanelComponent {
centerStrength: 0.05
});
searchQuery = signal<string>('');
colorGroups = signal<GraphColorGroup[]>([]);
forcesExpanded = signal(false);
optionsChanged = output<GraphOptions>();
animateRequested = output<void>();
constructor(private graphSettings: GraphSettingsService) {
// Sync with graph settings service
effect(() => {
const config = this.graphSettings.config();
this.filters.set({
showTags: config.showTags,
showAttachments: config.showAttachments,
existingFilesOnly: config.hideUnresolved,
showOrphans: config.showOrphans,
searchQuery: config.search
});
this.searchQuery.set(config.search);
this.colorGroups.set([...config.colorGroups]);
this.emitOptionsChange();
});
}
emitOptionsChange(): void {
this.optionsChanged.emit({
filters: this.filters(),
display: this.display(),
forces: this.forces()
});
// Save to settings service
this.graphSettings.save({
showTags: this.filters().showTags,
showAttachments: this.filters().showAttachments,
hideUnresolved: this.filters().existingFilesOnly,
showOrphans: this.filters().showOrphans,
search: this.searchQuery()
});
}
onSearchChange(query: string): void {
this.searchQuery.set(query);
this.filters.update(f => ({ ...f, searchQuery: query }));
this.graphSettings.save({ search: query });
this.emitOptionsChange();
}
onSearchSubmit(query: string): void {
// Add to history and apply
this.onSearchChange(query);
}
addNewGroup(): void {
const newGroup: GraphColorGroup = {
query: 'file:test',
color: createGraphColor('#E879F9', 1) // Purple default
};
const updated = [...this.colorGroups(), newGroup];
this.colorGroups.set(updated);
this.graphSettings.save({ colorGroups: updated });
}
removeGroup(index: number): void {
const updated = this.colorGroups().filter((_, i) => i !== index);
this.colorGroups.set(updated);
this.graphSettings.save({ colorGroups: updated });
}
updateGroupQuery(index: number, query: string): void {
const updated = this.colorGroups().map((g, i) =>
i === index ? { ...g, query } : g
);
this.colorGroups.set(updated);
this.graphSettings.save({ colorGroups: updated });
}
getGroupColorPreview(group: GraphColorGroup): string {
const hex = intToHex(group.color.rgb);
return `${hex}${Math.round(group.color.a * 255).toString(16).padStart(2, '0')}`;
}
}
@@ -4,23 +4,33 @@ import { GraphViewComponent, GraphDisplayOptions } from '../graph-view/graph-vie
import { GraphOptionsPanelComponent, GraphOptions } from '../graph-options-panel/graph-options-panel.component';
import { GraphData } from '../../types';
import { NoteIndexService } from '../../services/note-index.service';
import { VaultService } from '../../services/vault.service';
import { GraphSettingsService } from '../../app/graph/graph-settings.service';
import { GraphSettingsPanelComponent } from '../../app/graph/ui/settings-panel.component';
import { GraphRuntimeAdapter } from '../../app/graph/graph-runtime-adapter';
@Component({
selector: 'app-graph-view-container',
standalone: true,
imports: [CommonModule, GraphViewComponent, GraphOptionsPanelComponent],
imports: [
CommonModule,
GraphViewComponent,
GraphOptionsPanelComponent,
GraphSettingsPanelComponent
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="graph-view-container flex h-full w-full">
<div class="graph-view-container flex h-full w-full relative">
<!-- Graph View -->
<div class="flex-1 relative">
<app-graph-view
[graphData]="graphData()"
[graphData]="filteredGraphData()"
[displayOptions]="displayOptions()"
(nodeSelected)="onNodeSelected($event)">
(nodeSelected)="onNodeSelected($event)"
(settingsRequested)="openSettingsPanel()">
</app-graph-view>
<!-- Toggle Options Button (Mobile) -->
<!-- Toggle Options Button (Mobile - Old Panel) -->
<button
type="button"
(click)="toggleOptionsPanel()"
@@ -35,7 +45,7 @@ import { NoteIndexService } from '../../services/note-index.service';
</button>
</div>
<!-- Options Panel -->
<!-- Old Options Panel (kept for backward compatibility) -->
<div
class="options-panel-wrapper"
[class.open]="optionsPanelOpen()"
@@ -46,6 +56,13 @@ import { NoteIndexService } from '../../services/note-index.service';
</app-graph-options-panel>
</div>
<!-- New Settings Panel -->
<app-graph-settings-panel
[isOpen]="settingsPanelOpen()"
(close)="closeSettingsPanel()"
(animateRequested)="onAnimateRequested()">
</app-graph-settings-panel>
<!-- Overlay for mobile -->
@if (optionsPanelOpen() && isMobile()) {
<div
@@ -94,10 +111,14 @@ export class GraphViewContainerComponent {
nodeSelected = output<string>();
private noteIndexService = inject(NoteIndexService);
private vaultService = inject(VaultService);
private graphSettingsService = inject(GraphSettingsService);
optionsPanelOpen = signal(false);
settingsPanelOpen = signal(false);
graphData = computed<GraphData>(() => {
// Base graph data from note index
private baseGraphData = computed<GraphData>(() => {
const centerNote = this.centerNoteId();
if (!centerNote) {
return this.noteIndexService.buildFullGraphData();
@@ -105,46 +126,53 @@ export class GraphViewContainerComponent {
return this.noteIndexService.buildGraphData(centerNote, 2);
});
displayOptions = signal<GraphDisplayOptions>({
showArrows: true,
textFadeThreshold: 50,
nodeSize: 5,
linkThickness: 1,
chargeStrength: -100,
linkDistance: 100,
centerStrength: 0.05,
centerNodeId: undefined
// Filtered graph data based on settings
filteredGraphData = computed<GraphData>(() => {
const baseData = this.baseGraphData();
const config = this.graphSettingsService.config();
const allNotes = this.vaultService.allNotes();
return GraphRuntimeAdapter.applyFilters(baseData, config, allNotes);
});
constructor() {
effect(() => {
const centerNote = this.centerNoteId();
if (centerNote) {
this.displayOptions.update(opts => ({
...opts,
centerNodeId: centerNote
}));
}
// Display options from graph config
displayOptions = computed<GraphDisplayOptions>(() => {
const config = this.graphSettingsService.config();
const baseOptions = GraphRuntimeAdapter.configToDisplayOptions(config);
const graphData = this.filteredGraphData();
const allNotes = this.vaultService.allNotes();
const nodeColors = GraphRuntimeAdapter.applyColorGroups(graphData.nodes, config, allNotes);
return {
...baseOptions,
centerNodeId: this.centerNoteId(),
nodeColors
};
});
onOptionsChanged(options: GraphOptions): void {
// Old options panel - update graph settings service
this.graphSettingsService.save({
showArrow: options.display.showArrows,
textFadeMultiplier: this.convertTextFadeThreshold(options.display.textFadeThreshold),
nodeSizeMultiplier: options.display.nodeSize / 5,
lineSizeMultiplier: options.display.linkThickness,
repelStrength: -options.forces.chargeStrength / 10,
linkDistance: options.forces.linkDistance,
centerStrength: options.forces.centerStrength
});
}
onOptionsChanged(options: GraphOptions): void {
this.displayOptions.update(current => ({
...current,
showArrows: options.display.showArrows,
textFadeThreshold: options.display.textFadeThreshold,
nodeSize: options.display.nodeSize,
linkThickness: options.display.linkThickness,
chargeStrength: options.forces.chargeStrength,
linkDistance: options.forces.linkDistance,
centerStrength: options.forces.centerStrength
}));
onAnimateRequested(): void {
// Force graph to re-render by triggering effect
// Simply refresh the settings to trigger computed signals
const current = this.graphSettingsService.config();
this.graphSettingsService.save({ ...current });
}
onAnimateRequested(): void {
// Trigger animation restart
// The graph component will handle this via its effect on displayOptions
this.displayOptions.update(opts => ({ ...opts }));
private convertTextFadeThreshold(threshold: number): number {
// Convert 0-100 threshold back to -3 to 3 multiplier
return ((threshold / 100) * 6) - 3;
}
onNodeSelected(nodeId: string): void {
@@ -155,6 +183,14 @@ export class GraphViewContainerComponent {
this.optionsPanelOpen.update(open => !open);
}
closeSettingsPanel(): void {
this.settingsPanelOpen.set(false);
}
openSettingsPanel(): void {
this.settingsPanelOpen.set(true);
}
isMobile(): boolean {
return typeof window !== 'undefined' && window.innerWidth < 769;
}
@@ -28,6 +28,7 @@ export interface GraphDisplayOptions {
linkDistance: number;
centerStrength: number;
centerNodeId?: string;
nodeColors?: Map<string, string>; // Map of node ID to color
}
@Component({
@@ -79,7 +80,8 @@ export interface GraphDisplayOptions {
[attr.r]="displayOptions().nodeSize"
[class.ring-4]="node.id === displayOptions().centerNodeId"
[class.ring-blue-500]="node.id === displayOptions().centerNodeId"
class="fill-blue-500 dark:fill-blue-400 transition-all group-hover:fill-blue-600 dark:group-hover:fill-blue-300"
[attr.fill]="getNodeColor(node)"
class="transition-all"
[attr.opacity]="getNodeOpacity(node)">
</circle>
@@ -101,6 +103,22 @@ export interface GraphDisplayOptions {
<div><strong>Nodes:</strong> {{ simulatedNodes().length }}</div>
<div><strong>Links:</strong> {{ edges().length }}</div>
</div>
<!-- Settings button -->
<button
type="button"
(click)="settingsRequested.emit()"
(keydown.enter)="settingsRequested.emit()"
(keydown.space)="settingsRequested.emit(); $event.preventDefault()"
class="graph-settings-button"
aria-label="Graph settings"
title="Graph settings">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
`,
styles: [`
@@ -117,6 +135,56 @@ export interface GraphDisplayOptions {
svg.dragging {
cursor: grabbing;
}
.graph-settings-button {
position: absolute;
top: 1.25rem;
right: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: 9999px;
background-color: rgba(255, 255, 255, 0.96);
color: #1F2937;
box-shadow: 0 12px 28px -8px rgba(15, 23, 42, 0.5), 0 8px 16px -10px rgba(30, 41, 59, 0.35);
border: 1px solid rgba(148, 163, 184, 0.45);
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out, background-color 0.2s ease-in-out;
cursor: pointer;
outline: none;
z-index: 60;
}
.graph-settings-button:hover {
background-color: rgba(248, 250, 252, 1);
color: #0F172A;
transform: rotate(90deg);
box-shadow: 0 18px 32px -10px rgba(15, 23, 42, 0.55), 0 12px 20px -12px rgba(30, 41, 59, 0.45);
border-color: rgba(148, 163, 184, 0.6);
}
.graph-settings-button:focus {
outline: 2px solid #3B82F6;
outline-offset: 2px;
}
.graph-settings-button:active {
transform: rotate(90deg) scale(0.95);
}
:host-context(.dark) .graph-settings-button {
background-color: rgba(30, 41, 59, 0.92);
color: #E2E8F0;
border-color: rgba(148, 163, 184, 0.55);
box-shadow: 0 14px 32px -10px rgba(8, 47, 73, 0.6), 0 12px 20px -12px rgba(15, 23, 42, 0.6);
}
:host-context(.dark) .graph-settings-button:hover {
background-color: rgba(51, 65, 85, 0.95);
color: #F8FAFC;
border-color: rgba(226, 232, 240, 0.65);
}
`]
})
export class GraphViewComponent implements OnDestroy {
@@ -133,6 +201,7 @@ export class GraphViewComponent implements OnDestroy {
nodeSelected = output<string>();
nodeHovered = output<string>();
settingsRequested = output<void>();
svg = viewChild.required<ElementRef<SVGSVGElement>>('graphSvg');
@@ -355,5 +424,14 @@ export class GraphViewComponent implements OnDestroy {
selectNode(nodeId: string): void {
this.nodeSelected.emit(nodeId);
}
getNodeColor(node: SimulatedNode): string {
const nodeColors = this.displayOptions().nodeColors;
if (nodeColors && nodeColors.has(node.id)) {
return nodeColors.get(node.id)!;
}
// Default color
return '#3B82F6'; // blue-500
}
}
@@ -0,0 +1,363 @@
import {
Component,
Input,
Output,
EventEmitter,
signal,
ViewChild,
ElementRef,
ChangeDetectionStrategy,
AfterViewInit,
OnInit,
inject
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { SearchQueryAssistantComponent } from '../search-query-assistant/search-query-assistant.component';
import { SearchHistoryService } from '../../core/search/search-history.service';
import { SearchOptions } from '../../core/search/search-parser.types';
/**
* Main search bar component with Aa (case sensitivity) and .* (regex) buttons
* Provides a unified search interface with assistant popover
*/
@Component({
selector: 'app-search-bar',
standalone: true,
imports: [CommonModule, FormsModule, SearchQueryAssistantComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="relative w-full">
<div class="relative flex items-center gap-1">
<!-- Search icon -->
<svg
*ngIf="showSearchIcon"
class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-text-muted dark:text-gray-400 z-10"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<!-- Input field -->
<input
#searchInput
type="text"
[placeholder]="placeholder"
[(ngModel)]="query"
(input)="onInputChange()"
(focus)="onFocus()"
(keydown)="onKeyDown($event)"
(keydown.enter)="onEnter()"
(keydown.escape)="onEscape()"
[class]="inputClass"
[style.padding-left]="showSearchIcon ? '2.5rem' : '0.75rem'"
[style.padding-right]="'7rem'"
[attr.aria-label]="placeholder"
autocomplete="off"
spellcheck="false"
/>
<!-- Control buttons container -->
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<!-- Case sensitivity button (Aa) -->
<button
type="button"
(click)="toggleCaseSensitivity()"
[class.bg-accent]="caseSensitive()"
[class.text-white]="caseSensitive()"
[class.hover:bg-bg-muted]="!caseSensitive()"
[class.dark:hover:bg-gray-700]="!caseSensitive()"
class="px-2 py-1 rounded text-xs font-semibold transition-colors border border-border dark:border-gray-600"
[title]="caseSensitive() ? 'Case sensitive' : 'Case insensitive'"
aria-label="Toggle case sensitivity"
>
Aa
</button>
<!-- Regex mode button (.*) -->
<button
type="button"
(click)="toggleRegexMode()"
[class.bg-accent]="regexMode()"
[class.text-white]="regexMode()"
[class.hover:bg-bg-muted]="!regexMode()"
[class.dark:hover:bg-gray-700]="!regexMode()"
class="px-2 py-1 rounded text-xs font-mono transition-colors border border-border dark:border-gray-600"
[title]="regexMode() ? 'Regex mode enabled' : 'Regex mode disabled'"
aria-label="Toggle regex mode"
>
.*
</button>
<!-- Clear button -->
<button
*ngIf="query"
type="button"
(click)="clear()"
class="p-1 rounded-full hover:bg-bg-muted dark:hover:bg-gray-700 transition-colors"
aria-label="Clear search"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-text-muted dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Search assistant popover -->
<app-search-query-assistant
#assistant
[context]="context"
[currentQuery]="query"
[anchorElement]="anchorElement"
(queryChange)="onQueryChange($event)"
(querySubmit)="onQuerySubmit($event)"
/>
</div>
`,
styles: [`
:host {
display: block;
width: 100%;
}
input:focus {
outline: none;
}
`]
})
export class SearchBarComponent implements AfterViewInit, OnInit {
@Input() placeholder: string = 'Search in vault...';
@Input() context: string = 'vault';
@Input() showSearchIcon: boolean = true;
@Input() inputClass: string = 'w-full px-3 py-2 text-sm border border-border dark:border-gray-600 rounded-lg bg-bg-primary dark:bg-gray-800 text-text-main dark:text-gray-100 placeholder-text-muted dark:placeholder-gray-500 focus:ring-2 focus:ring-accent dark:focus:ring-blue-500 transition-all';
@Input() initialQuery: string = '';
@Output() search = new EventEmitter<{ query: string; options: SearchOptions }>();
@Output() queryChange = new EventEmitter<string>();
@ViewChild('searchInput') searchInputRef?: ElementRef<HTMLInputElement>;
@ViewChild('assistant') assistantRef?: SearchQueryAssistantComponent;
private historyService = inject(SearchHistoryService);
query = '';
caseSensitive = signal(false);
regexMode = signal(false);
anchorElement: HTMLElement | null = null;
private historyIndex = -1;
constructor(private hostElement: ElementRef<HTMLElement>) {}
ngOnInit(): void {
if (this.initialQuery) {
this.query = this.initialQuery;
}
}
ngAfterViewInit(): void {
this.anchorElement = this.hostElement.nativeElement;
}
/**
* Handle input changes
*/
onInputChange(): void {
this.queryChange.emit(this.query);
this.historyIndex = -1;
// Update suggestions in assistant
if (this.assistantRef) {
this.assistantRef.currentQuery = this.query;
this.assistantRef.updateSuggestions();
}
}
/**
* Handle focus event - open assistant
*/
onFocus(): void {
if (this.assistantRef) {
this.assistantRef.open();
}
}
/**
* Handle keyboard navigation
*/
onKeyDown(event: KeyboardEvent): void {
// Navigate history with up/down arrows when assistant is closed
if (this.assistantRef && !this.assistantRef.isOpen()) {
if (event.key === 'ArrowUp') {
event.preventDefault();
this.navigateHistory('up');
} else if (event.key === 'ArrowDown') {
event.preventDefault();
this.navigateHistory('down');
}
}
}
/**
* Handle Enter key - submit search
*/
onEnter(): void {
if (this.query.trim()) {
// Add to history
this.historyService.add(this.context, this.query);
// Emit search event with options
this.search.emit({
query: this.query,
options: {
caseSensitive: this.caseSensitive(),
regexMode: this.regexMode()
}
});
}
if (this.assistantRef) {
this.assistantRef.close();
}
}
/**
* Handle Escape key - close assistant
*/
onEscape(): void {
if (this.assistantRef) {
this.assistantRef.close();
}
}
/**
* Handle query change from assistant
*/
onQueryChange(newQuery: string): void {
this.query = newQuery;
this.queryChange.emit(newQuery);
// Update input value
if (this.searchInputRef) {
this.searchInputRef.nativeElement.value = newQuery;
}
}
/**
* Handle query submit from assistant
*/
onQuerySubmit(query: string): void {
this.query = query;
this.onEnter();
}
/**
* Toggle case sensitivity
*/
toggleCaseSensitivity(): void {
this.caseSensitive.update(v => !v);
// Re-run search if there's a query
if (this.query.trim()) {
this.search.emit({
query: this.query,
options: {
caseSensitive: this.caseSensitive(),
regexMode: this.regexMode()
}
});
}
}
/**
* Toggle regex mode
*/
toggleRegexMode(): void {
this.regexMode.update(v => !v);
// Re-run search if there's a query
if (this.query.trim()) {
this.search.emit({
query: this.query,
options: {
caseSensitive: this.caseSensitive(),
regexMode: this.regexMode()
}
});
}
}
/**
* Clear the search
*/
clear(): void {
this.query = '';
this.queryChange.emit('');
this.historyIndex = -1;
if (this.searchInputRef) {
this.searchInputRef.nativeElement.value = '';
this.searchInputRef.nativeElement.focus();
}
// Emit empty search to clear results
this.search.emit({
query: '',
options: {
caseSensitive: this.caseSensitive(),
regexMode: this.regexMode()
}
});
}
/**
* Navigate through search history
*/
private navigateHistory(direction: 'up' | 'down'): void {
const history = this.historyService.list(this.context);
if (history.length === 0) return;
if (direction === 'up') {
this.historyIndex = Math.min(this.historyIndex + 1, history.length - 1);
} else {
this.historyIndex = Math.max(this.historyIndex - 1, -1);
}
if (this.historyIndex >= 0) {
this.query = history[this.historyIndex];
this.queryChange.emit(this.query);
if (this.searchInputRef) {
this.searchInputRef.nativeElement.value = this.query;
}
} else {
this.query = '';
this.queryChange.emit('');
if (this.searchInputRef) {
this.searchInputRef.nativeElement.value = '';
}
}
}
/**
* Focus the input
*/
focus(): void {
if (this.searchInputRef) {
this.searchInputRef.nativeElement.focus();
}
}
/**
* Set the query programmatically
*/
setQuery(query: string): void {
this.query = query;
if (this.searchInputRef) {
this.searchInputRef.nativeElement.value = query;
}
}
}
@@ -0,0 +1,215 @@
import {
Component,
Input,
Output,
EventEmitter,
signal,
ViewChild,
ElementRef,
ChangeDetectionStrategy,
AfterViewInit
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { SearchQueryAssistantComponent } from '../search-query-assistant/search-query-assistant.component';
import { inject } from '@angular/core';
import { SearchHistoryService } from '../../core/search/search-history.service';
/**
* Search input with integrated query assistant
* Wraps a text input and manages the search assistant popover
*/
@Component({
selector: 'app-search-input-with-assistant',
standalone: true,
imports: [CommonModule, FormsModule, SearchQueryAssistantComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="relative">
<div class="relative">
<svg
*ngIf="showSearchIcon"
class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-text-muted"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
#searchInput
type="text"
[placeholder]="placeholder"
[value]="value"
(input)="onInputChange($event)"
(focus)="onFocus()"
(blur)="onBlur()"
(keydown.enter)="onEnter()"
[class]="inputClass"
[attr.aria-label]="placeholder"
/>
<button
*ngIf="value"
(click)="clear()"
class="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-bg-muted dark:hover:bg-gray-700 transition-colors"
aria-label="Clear search"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<app-search-query-assistant
#assistant
[context]="context"
[currentQuery]="value"
[anchorElement]="anchorElement"
(queryChange)="onQueryChange($event)"
(querySubmit)="onQuerySubmit($event)"
/>
</div>
`,
styles: [`
:host {
display: block;
}
`]
})
export class SearchInputWithAssistantComponent implements AfterViewInit {
@Input() placeholder: string = 'Search...';
@Input() value: string = '';
@Input() context: string = 'default';
@Input() showSearchIcon: boolean = true;
@Input() inputClass: string = 'w-full px-3 py-2 text-sm border border-border rounded-md bg-bg-primary text-text-main placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-accent';
@Output() valueChange = new EventEmitter<string>();
@Output() submit = new EventEmitter<string>();
@ViewChild('searchInput') searchInputRef?: ElementRef<HTMLInputElement>;
@ViewChild('assistant') assistantRef?: SearchQueryAssistantComponent;
anchorElement: HTMLElement | null = null;
private historyService = inject(SearchHistoryService);
constructor(private hostElement: ElementRef<HTMLElement>) {}
ngAfterViewInit(): void {
this.anchorElement = this.hostElement.nativeElement;
}
/**
* Handle input changes
*/
onInputChange(event: Event): void {
const target = event.target as HTMLInputElement;
this.value = target.value;
this.valueChange.emit(this.value);
// Update suggestions in assistant
if (this.assistantRef) {
this.assistantRef.currentQuery = this.value;
this.assistantRef.updateOptions();
this.assistantRef.updateSuggestions();
}
}
/**
* Handle focus event - open assistant
*/
onFocus(): void {
if (this.assistantRef) {
this.assistantRef.open();
}
}
/**
* Handle blur event
*/
onBlur(): void {
// Delay close to allow clicking on popover items
setTimeout(() => {
if (this.assistantRef && !this.assistantRef.popoverRef) {
// Only close if not interacting with popover
}
}, 200);
}
/**
* Handle Enter key
*/
onEnter(): void {
// Save to history and submit
if (this.value.trim()) {
this.historyService.add(this.context, this.value);
}
this.submit.emit(this.value);
if (this.assistantRef) {
this.assistantRef.refreshHistoryView();
this.assistantRef.close();
}
}
/**
* Handle query change from assistant
*/
onQueryChange(query: string): void {
this.value = query;
this.valueChange.emit(query);
if (this.searchInputRef) {
this.searchInputRef.nativeElement.value = query;
}
if (this.assistantRef) {
this.assistantRef.currentQuery = query;
this.assistantRef.updateOptions();
this.assistantRef.updateSuggestions();
}
}
/**
* Handle query submit from assistant
*/
onQuerySubmit(query: string): void {
this.value = query;
if (query.trim()) {
this.historyService.add(this.context, query);
}
this.submit.emit(query);
if (this.searchInputRef) {
this.searchInputRef.nativeElement.value = query;
this.searchInputRef.nativeElement.focus();
}
if (this.assistantRef) {
this.assistantRef.currentQuery = query;
this.assistantRef.refreshHistoryView();
this.assistantRef.close();
}
}
/**
* Clear the input
*/
clear(): void {
this.value = '';
this.valueChange.emit('');
if (this.searchInputRef) {
this.searchInputRef.nativeElement.value = '';
this.searchInputRef.nativeElement.focus();
}
}
/**
* Focus the input
*/
focus(): void {
if (this.searchInputRef) {
this.searchInputRef.nativeElement.focus();
}
}
}
@@ -0,0 +1,174 @@
import {
Component,
Input,
Output,
EventEmitter,
signal,
OnInit,
inject,
ChangeDetectionStrategy
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { SearchBarComponent } from '../search-bar/search-bar.component';
import { SearchResultsComponent } from '../search-results/search-results.component';
import { SearchEvaluatorService, SearchResult } from '../../core/search/search-evaluator.service';
import { SearchIndexService } from '../../core/search/search-index.service';
import { SearchOptions } from '../../core/search/search-parser.types';
import { VaultService } from '../../services/vault.service';
/**
* Complete search panel with bar and results
* Integrates search bar, results display, and search execution
*/
@Component({
selector: 'app-search-panel',
standalone: true,
imports: [CommonModule, SearchBarComponent, SearchResultsComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex flex-col h-full bg-bg-primary dark:bg-gray-900">
<!-- Search bar -->
<div class="p-4 border-b border-border dark:border-gray-700">
<app-search-bar
[placeholder]="placeholder"
[context]="context"
[showSearchIcon]="true"
(search)="onSearch($event)"
(queryChange)="onQueryChange($event)"
/>
</div>
<!-- Search results -->
<div class="flex-1 overflow-hidden">
@if (isSearching()) {
<div class="flex items-center justify-center h-full">
<div class="flex flex-col items-center gap-3">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-accent dark:border-blue-400"></div>
<p class="text-sm text-text-muted dark:text-gray-400">Searching...</p>
</div>
</div>
} @else if (hasSearched() && results().length === 0) {
<div class="flex flex-col items-center justify-center h-full text-text-muted dark:text-gray-400 p-8">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm font-medium mb-1">No results found</p>
<p class="text-xs">Try adjusting your search query</p>
</div>
} @else if (results().length > 0) {
<app-search-results
[results]="results()"
(noteOpen)="onNoteOpen($event)"
/>
} @else {
<div class="flex flex-col items-center justify-center h-full text-text-muted dark:text-gray-400 p-8">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<p class="text-sm">Start typing to search</p>
</div>
}
</div>
</div>
`,
styles: [`
:host {
display: block;
height: 100%;
}
`]
})
export class SearchPanelComponent implements OnInit {
@Input() placeholder: string = 'Search in vault...';
@Input() context: string = 'vault';
@Output() noteOpen = new EventEmitter<{ noteId: string; line?: number }>();
private searchEvaluator = inject(SearchEvaluatorService);
private searchIndex = inject(SearchIndexService);
private vaultService = inject(VaultService);
results = signal<SearchResult[]>([]);
isSearching = signal(false);
hasSearched = signal(false);
currentQuery = signal('');
ngOnInit(): void {
// Build search index from vault notes
this.rebuildIndex();
// Rebuild index when vault changes (could be optimized with incremental updates)
// For now, we'll rebuild on init
}
/**
* Rebuild the search index
*/
private rebuildIndex(): void {
const notes = this.vaultService.allNotes();
this.searchIndex.rebuildIndex(notes);
}
/**
* Handle search execution
*/
onSearch(event: { query: string; options: SearchOptions }): void {
const { query, options } = event;
if (!query || !query.trim()) {
this.results.set([]);
this.hasSearched.set(false);
return;
}
this.isSearching.set(true);
this.currentQuery.set(query);
// Execute search asynchronously to avoid blocking UI
setTimeout(() => {
try {
const searchResults = this.searchEvaluator.search(query, options);
this.results.set(searchResults);
this.hasSearched.set(true);
} catch (error) {
console.error('Search error:', error);
this.results.set([]);
this.hasSearched.set(true);
} finally {
this.isSearching.set(false);
}
}, 0);
}
/**
* Handle query change (for live search if needed)
*/
onQueryChange(query: string): void {
this.currentQuery.set(query);
// Could implement debounced live search here
// For now, only search on Enter
}
/**
* Handle note open from results
*/
onNoteOpen(event: { noteId: string; line?: number }): void {
this.noteOpen.emit(event);
}
/**
* Refresh the search index
*/
refreshIndex(): void {
this.rebuildIndex();
// Re-run current search if there is one
if (this.currentQuery()) {
this.onSearch({
query: this.currentQuery(),
options: {}
});
}
}
}
@@ -0,0 +1,493 @@
import {
Component,
Input,
Output,
EventEmitter,
signal,
ChangeDetectionStrategy,
ElementRef,
ViewChild,
HostListener,
inject
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { SearchHistoryService } from '../../core/search/search-history.service';
import { SearchAssistantService, SearchOption } from '../../core/search/search-assistant.service';
type NavigationItem =
| { type: 'option'; index: number; option: SearchOption }
| { type: 'suggestion'; index: number; value: string }
| { type: 'history'; index: number; value: string };
/**
* Search query assistant component
* Provides popover with search options, suggestions, and history
* Based on Obsidian's search interface
*/
@Component({
selector: 'app-search-query-assistant',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="relative">
<!-- Popover -->
@if (isOpen()) {
<div
#popover
class="absolute top-full left-0 right-0 mt-1 bg-bg-primary dark:bg-gray-800 border border-border dark:border-gray-700 rounded-xl shadow-2xl z-50 overflow-hidden max-h-[360px]"
(click)="$event.stopPropagation()"
>
<!-- Search Options -->
<div class="p-3 border-b border-border dark:border-gray-700">
<div class="flex items-center justify-between mb-2">
<h4 class="text-xs font-semibold uppercase tracking-wide text-text-main dark:text-gray-100">Search options</h4>
<button
(click)="showHelp = !showHelp"
class="p-1 rounded-full hover:bg-bg-muted dark:hover:bg-gray-700 transition-colors"
title="Show help"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
@if (showHelp) {
<div class="mb-2 p-2 bg-bg-muted dark:bg-gray-900 rounded-lg text-[11px] space-y-1.5">
<div><strong>Operators:</strong></div>
<div class="grid grid-cols-2 gap-1.5 text-text-muted dark:text-gray-400">
<div><code class="px-1 py-0.5 bg-bg-primary dark:bg-gray-800 rounded">OR</code> Either term</div>
<div><code class="px-1 py-0.5 bg-bg-primary dark:bg-gray-800 rounded">-term</code> Exclude</div>
<div><code class="px-1 py-0.5 bg-bg-primary dark:bg-gray-800 rounded">"phrase"</code> Exact match</div>
<div><code class="px-1 py-0.5 bg-bg-primary dark:bg-gray-800 rounded">term*</code> Wildcard</div>
</div>
<div><strong>Combine:</strong> <code class="px-1 py-0.5 bg-bg-primary dark:bg-gray-800 rounded">tag:#todo OR tag:#urgent</code></div>
</div>
}
<div class="space-y-1 max-h-[200px] overflow-y-auto transition-all pr-1">
<button
*ngFor="let option of searchOptions(); let i = index"
(click)="insertOption(option.prefix)"
class="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-bg-muted dark:hover:bg-gray-700 transition-colors group text-sm"
[ngClass]="{
'bg-bg-muted': isSelected('option', i),
'dark:bg-gray-700': isSelected('option', i)
}"
>
<div class="flex items-center gap-2">
<code class="text-xs font-mono text-accent dark:text-blue-400 whitespace-nowrap">{{ option.prefix }}</code>
<div class="flex-1 min-w-0 text-[13px] text-text-muted dark:text-gray-400 group-hover:text-text-main dark:group-hover:text-gray-100">
{{ option.description }}
</div>
<span
*ngIf="option.example"
class="text-[11px] text-text-muted dark:text-gray-500 font-mono ml-2 truncate"
>
{{ option.example }}
</span>
</div>
</button>
</div>
</div>
<!-- History -->
@if (history().length > 0) {
<div class="p-3 border-b border-border dark:border-gray-700">
<div class="flex items-center justify-between mb-2">
<h4 class="text-xs font-semibold uppercase tracking-wide text-text-main dark:text-gray-100">History</h4>
<button
(click)="clearHistory()"
class="text-[11px] text-text-muted dark:text-gray-400 hover:text-accent dark:hover:text-blue-400 transition-colors"
>
Clear
</button>
</div>
<div class="space-y-1 max-h-[150px] overflow-y-auto pr-1">
<button
*ngFor="let item of history(); let i = index"
(click)="selectHistoryItem(item)"
class="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-bg-muted dark:hover:bg-gray-700 transition-colors group text-sm"
[ngClass]="{
'bg-bg-muted': isSelected('history', i),
'dark:bg-gray-700': isSelected('history', i)
}"
>
<span class="block truncate text-[13px] text-text-muted dark:text-gray-300 group-hover:text-text-main dark:group-hover:text-gray-100 font-mono">
{{ item }}
</span>
</button>
</div>
</div>
}
<!-- Dynamic Suggestions -->
@if (suggestions().length > 0) {
<div class="p-3">
<h4 class="text-xs font-semibold uppercase tracking-wide text-text-main dark:text-gray-100 mb-2">
{{ suggestionsTitle() }}
</h4>
<div class="space-y-1 max-h-[140px] overflow-y-auto pr-1">
<button
*ngFor="let suggestion of suggestions(); let i = index"
(click)="insertSuggestion(suggestion)"
class="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-bg-muted dark:hover:bg-gray-700 transition-colors text-sm"
[ngClass]="{
'bg-bg-muted': isSelected('suggestion', i),
'dark:bg-gray-700': isSelected('suggestion', i)
}"
>
<span class="block truncate text-[13px] text-text-main dark:text-gray-200">{{ suggestion }}</span>
</button>
</div>
</div>
}
</div>
}
</div>
`,
styles: [`
:host {
display: block;
position: relative;
}
code {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
:host-context(.dark) ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
`]
})
export class SearchQueryAssistantComponent {
@Input() context: string = 'default'; // Search context (vault, graph, etc.)
@Input() currentQuery: string = '';
@Input() anchorElement: HTMLElement | null = null;
@Output() queryChange = new EventEmitter<string>();
@Output() querySubmit = new EventEmitter<string>();
@ViewChild('popover') popoverRef?: ElementRef;
isOpen = signal(false);
showHelp = false;
selectedIndex = signal(-1);
navigationItems = signal<NavigationItem[]>([]);
history = signal<string[]>([]);
private historyService = inject(SearchHistoryService);
private assistantService = inject(SearchAssistantService);
private elementRef = inject(ElementRef);
searchOptions = signal<SearchOption[]>([]);
// Keep track of the original query when starting keyboard navigation previews
private originalQueryForPreview: string | null = null;
/**
* Open the assistant popover
*/
open(): void {
this.isOpen.set(true);
this.refreshHistory();
this.updateOptions();
this.updateSuggestions();
}
/**
* Close the assistant popover
*/
close(): void {
this.isOpen.set(false);
this.selectedIndex.set(-1);
this.navigationItems.set([]);
// Reset any preview state on close
this.originalQueryForPreview = null;
}
/**
* Toggle popover
*/
toggle(): void {
if (this.isOpen()) {
this.close();
} else {
this.open();
}
}
/**
* History items
*/
/**
* Dynamic suggestions based on current query
*/
suggestions = signal<string[]>([]);
/**
* Title for suggestions section
*/
suggestionsTitle = signal<string>('Suggestions');
/**
* Update search options based on current query
*/
updateOptions(): void {
const filtered = this.assistantService.getFilteredOptions(this.currentQuery);
this.searchOptions.set(filtered);
this.refreshNavigation();
}
/**
* Update suggestions based on current query
*/
updateSuggestions(): void {
const result = this.assistantService.getSuggestions(this.currentQuery);
this.suggestions.set(result.items);
this.suggestionsTitle.set(result.type);
this.refreshNavigation();
}
/**
* Insert a search option prefix
*/
insertOption(prefix: string): void {
const optionValue = prefix === '[property]' ? '[' : prefix;
const { newQuery } = this.assistantService.insertOption(
this.currentQuery,
optionValue,
this.currentQuery.length
);
this.currentQuery = newQuery;
this.queryChange.emit(newQuery);
this.updateOptions();
this.updateSuggestions();
this.selectedIndex.set(-1);
}
/**
* Insert a suggestion
*/
insertSuggestion(suggestion: string): void {
const newQuery = this.assistantService.insertSuggestion(this.currentQuery, suggestion);
this.currentQuery = newQuery;
this.queryChange.emit(newQuery);
this.close();
}
/**
* Select a history item
*/
selectHistoryItem(item: string): void {
this.currentQuery = item;
this.queryChange.emit(item);
this.close();
}
/**
* Clear history
*/
clearHistory(): void {
this.historyService.clear(this.context);
this.refreshHistory();
}
/**
* Handle keyboard navigation
*/
@HostListener('document:keydown', ['$event'])
handleKeyboard(event: KeyboardEvent): void {
if (!this.isOpen()) return;
const items = this.navigationItems();
if (items.length === 0) {
if (event.key === 'Enter') {
this.querySubmit.emit(this.currentQuery);
this.close();
event.preventDefault();
event.stopPropagation();
}
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
event.stopPropagation();
const next = this.selectedIndex() + 1;
this.selectedIndex.set(next >= items.length ? 0 : next);
this.previewSelectedItem();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
event.stopPropagation();
const prev = this.selectedIndex() - 1;
this.selectedIndex.set(prev < 0 ? items.length - 1 : prev);
this.previewSelectedItem();
} else if (event.key === 'Enter' && this.selectedIndex() >= 0) {
event.preventDefault();
event.stopPropagation();
// Commit the selection and clear preview baseline
const applied = this.applySelectedItem();
if (applied) {
this.originalQueryForPreview = null;
}
} else if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
// Revert any previewed value
if (this.originalQueryForPreview !== null) {
this.currentQuery = this.originalQueryForPreview;
this.queryChange.emit(this.currentQuery);
}
this.close();
}
}
/**
* Close on outside click
*/
@HostListener('document:click', ['$event'])
handleOutsideClick(event: MouseEvent): void {
if (!this.isOpen()) return;
const target = event.target as HTMLElement;
const insideAssistant = this.elementRef.nativeElement.contains(target);
const insideAnchor = this.anchorElement ? this.anchorElement.contains(target) : false;
if (!insideAssistant && !insideAnchor) {
this.close();
}
}
private refreshNavigation(): void {
if (!this.isOpen()) {
this.navigationItems.set([]);
this.selectedIndex.set(-1);
this.originalQueryForPreview = null;
return;
}
const items: NavigationItem[] = [];
const options = this.searchOptions();
options.forEach((option, index) => {
items.push({ type: 'option', index, option });
});
const historyItems = this.history();
historyItems.forEach((value, index) => {
items.push({ type: 'history', index, value });
});
const suggestions = this.suggestions();
suggestions.forEach((value, index) => {
items.push({ type: 'suggestion', index, value });
});
this.navigationItems.set(items);
if (items.length === 0) {
this.selectedIndex.set(-1);
} else if (this.selectedIndex() >= items.length) {
this.selectedIndex.set(items.length - 1);
}
}
private refreshHistory(): void {
const historyItems = this.historyService.list(this.context);
this.history.set(historyItems);
this.refreshNavigation();
}
/**
* Public method to refresh history from parent component
*/
refreshHistoryView(): void {
this.refreshHistory();
}
applySelectedItem(): boolean {
if (!this.isOpen()) return false;
const index = this.selectedIndex();
if (index < 0) return false;
const items = this.navigationItems();
const selected = items[index];
if (!selected) return false;
if (selected.type === 'suggestion') {
this.insertSuggestion(selected.value);
return true;
}
if (selected.type === 'option') {
this.insertOption(selected.option.prefix);
return true;
}
if (selected.type === 'history') {
this.selectHistoryItem(selected.value);
return true;
}
return false;
}
/**
* Preview the currently selected item into the input without committing.
* Restores original value on Escape or when closing.
*/
private previewSelectedItem(): void {
const index = this.selectedIndex();
const items = this.navigationItems();
if (index < 0 || index >= items.length) {
return;
}
// Capture the baseline only once at the start of navigation
if (this.originalQueryForPreview === null) {
this.originalQueryForPreview = this.currentQuery;
}
const selected = items[index];
let previewQuery = this.originalQueryForPreview;
if (selected.type === 'suggestion') {
previewQuery = this.assistantService.insertSuggestion(this.originalQueryForPreview!, selected.value);
} else if (selected.type === 'option') {
const optionValue = selected.option.prefix === '[property]' ? '[' : selected.option.prefix;
const { newQuery } = this.assistantService.insertOption(
this.originalQueryForPreview!,
optionValue,
this.originalQueryForPreview!.length
);
previewQuery = newQuery;
} else if (selected.type === 'history') {
previewQuery = selected.value;
}
this.currentQuery = previewQuery ?? this.currentQuery;
this.queryChange.emit(this.currentQuery);
}
isSelected(type: NavigationItem['type'], index: number): boolean {
const selected = this.navigationItems()[this.selectedIndex()];
if (!selected) return false;
return selected.type === type && selected.index === index;
}
}
@@ -0,0 +1,349 @@
import {
Component,
Input,
Output,
EventEmitter,
signal,
computed,
ChangeDetectionStrategy,
inject
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { SearchResult, SearchMatch } from '../../core/search/search-evaluator.service';
import { VaultService } from '../../services/vault.service';
/**
* Group of search results by file
*/
interface ResultGroup {
noteId: string;
fileName: string;
filePath: string;
matches: SearchMatch[];
matchCount: number;
isExpanded: boolean;
score: number;
}
/**
* Sort option for results
*/
type SortOption = 'relevance' | 'name' | 'modified';
/**
* Search results component
* Displays search results grouped by file with highlighting and navigation
*/
@Component({
selector: 'app-search-results',
standalone: true,
imports: [CommonModule, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex flex-col h-full bg-bg-primary dark:bg-gray-900">
<!-- Results header -->
<div class="flex items-center justify-between p-4 border-b border-border dark:border-gray-700">
<div class="flex items-center gap-3">
<h3 class="text-sm font-semibold text-text-main dark:text-gray-100">
{{ totalResults() }} {{ totalResults() === 1 ? 'result' : 'results' }}
@if (totalMatches() > 0) {
<span class="text-text-muted dark:text-gray-400">
({{ totalMatches() }} {{ totalMatches() === 1 ? 'match' : 'matches' }})
</span>
}
</h3>
</div>
<!-- Sort options -->
<div class="flex items-center gap-2">
<label class="text-xs text-text-muted dark:text-gray-400">Sort:</label>
<select
[(ngModel)]="sortBy"
(change)="onSortChange()"
class="text-xs px-2 py-1 rounded border border-border dark:border-gray-600 bg-bg-primary dark:bg-gray-800 text-text-main dark:text-gray-100"
>
<option value="relevance">Relevance</option>
<option value="name">Name</option>
<option value="modified">Modified</option>
</select>
<!-- Expand/Collapse all -->
<button
(click)="toggleAllGroups()"
class="text-xs px-2 py-1 rounded hover:bg-bg-muted dark:hover:bg-gray-700 text-text-muted dark:text-gray-400"
title="Expand/Collapse all"
>
{{ allExpanded() ? 'Collapse all' : 'Expand all' }}
</button>
</div>
</div>
<!-- Results list -->
<div class="flex-1 overflow-y-auto">
@if (sortedGroups().length === 0) {
<div class="flex flex-col items-center justify-center h-full text-text-muted dark:text-gray-400 p-8">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<p class="text-sm">No results found</p>
</div>
} @else {
<div class="divide-y divide-border dark:divide-gray-700">
@for (group of sortedGroups(); track group.noteId) {
<div class="hover:bg-bg-muted dark:hover:bg-gray-800 transition-colors">
<!-- File header -->
<div
class="flex items-center justify-between p-3 cursor-pointer"
(click)="toggleGroup(group)"
>
<div class="flex items-center gap-2 flex-1 min-w-0">
<!-- Expand/collapse icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-text-muted dark:text-gray-400 transition-transform"
[class.rotate-90]="group.isExpanded"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<!-- File icon -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-accent dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<!-- File name and path -->
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-text-main dark:text-gray-100 truncate">
{{ group.fileName }}
</div>
<div class="text-xs text-text-muted dark:text-gray-400 truncate">
{{ group.filePath }}
</div>
</div>
<!-- Match count badge -->
<span class="px-2 py-0.5 text-xs rounded-full bg-accent/10 dark:bg-blue-500/10 text-accent dark:text-blue-400">
{{ group.matchCount }} {{ group.matchCount === 1 ? 'match' : 'matches' }}
</span>
</div>
<!-- Open button -->
<button
(click)="openNote(group.noteId, $event)"
class="ml-2 p-1.5 rounded hover:bg-bg-primary dark:hover:bg-gray-700 text-text-muted dark:text-gray-400"
title="Open note"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
</div>
<!-- Matches (expanded) -->
@if (group.isExpanded) {
<div class="px-3 pb-3 pl-11 space-y-2">
@for (match of group.matches; track $index) {
<div
class="p-2 rounded bg-bg-muted dark:bg-gray-800 hover:bg-bg-secondary dark:hover:bg-gray-700 cursor-pointer transition-colors"
(click)="openNote(group.noteId, $event, match.line)"
>
<!-- Match type badge -->
<div class="flex items-center gap-2 mb-1">
<span class="text-xs px-1.5 py-0.5 rounded bg-bg-primary dark:bg-gray-900 text-text-muted dark:text-gray-400 font-mono">
{{ match.type }}
</span>
@if (match.line) {
<span class="text-xs text-text-muted dark:text-gray-500">
Line {{ match.line }}
</span>
}
</div>
<!-- Match context with highlighting -->
<div class="text-sm text-text-main dark:text-gray-200 font-mono leading-relaxed">
<span [innerHTML]="highlightMatch(match.context, match.text)"></span>
</div>
</div>
}
</div>
}
</div>
}
</div>
}
</div>
</div>
`,
styles: [`
:host {
display: block;
height: 100%;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
:host-context(.dark) ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
`]
})
export class SearchResultsComponent {
@Input() set results(value: SearchResult[]) {
this.buildGroups(value);
}
@Output() noteOpen = new EventEmitter<{ noteId: string; line?: number }>();
private vaultService = inject(VaultService);
private groups = signal<ResultGroup[]>([]);
sortBy: SortOption = 'relevance';
/**
* Total number of result files
*/
totalResults = computed(() => this.groups().length);
/**
* Total number of matches across all files
*/
totalMatches = computed(() => {
return this.groups().reduce((sum, group) => sum + group.matchCount, 0);
});
/**
* Check if all groups are expanded
*/
allExpanded = computed(() => {
const groups = this.groups();
return groups.length > 0 && groups.every(g => g.isExpanded);
});
/**
* Sorted groups based on current sort option
*/
sortedGroups = computed(() => {
const groups = [...this.groups()];
switch (this.sortBy) {
case 'relevance':
return groups.sort((a, b) => b.score - a.score);
case 'name':
return groups.sort((a, b) => a.fileName.localeCompare(b.fileName));
case 'modified':
// Would need modification time from vault service
return groups;
default:
return groups;
}
});
/**
* Build result groups from search results
*/
private buildGroups(results: SearchResult[]): void {
const groups: ResultGroup[] = results.map(result => {
// Get note info from vault service
const note = this.vaultService.getNoteById(result.noteId);
const fileName = note?.fileName || result.noteId;
const filePath = note?.filePath || result.noteId;
return {
noteId: result.noteId,
fileName,
filePath,
matches: result.matches,
matchCount: result.matches.length,
isExpanded: true, // Expand by default
score: result.score
};
});
this.groups.set(groups);
}
/**
* Toggle a result group
*/
toggleGroup(group: ResultGroup): void {
this.groups.update(groups => {
return groups.map(g =>
g.noteId === group.noteId
? { ...g, isExpanded: !g.isExpanded }
: g
);
});
}
/**
* Toggle all groups
*/
toggleAllGroups(): void {
const shouldExpand = !this.allExpanded();
this.groups.update(groups => {
return groups.map(g => ({ ...g, isExpanded: shouldExpand }));
});
}
/**
* Handle sort change
*/
onSortChange(): void {
// Sorting is handled by computed property
}
/**
* Open a note
*/
openNote(noteId: string, event: Event, line?: number): void {
event.stopPropagation();
this.noteOpen.emit({ noteId, line });
}
/**
* Highlight matched text in context
*/
highlightMatch(context: string, matchText: string): string {
if (!matchText || !context) {
return this.escapeHtml(context);
}
const escapedContext = this.escapeHtml(context);
const escapedMatch = this.escapeHtml(matchText);
// Case-insensitive replacement with highlighting
const regex = new RegExp(`(${escapedMatch})`, 'gi');
return escapedContext.replace(
regex,
'<mark class="bg-yellow-200 dark:bg-yellow-600 text-text-main dark:text-gray-900 px-0.5 rounded">$1</mark>'
);
}
/**
* Escape HTML to prevent XSS
*/
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}