+
+ (nodeSelected)="onNodeSelected($event)"
+ (settingsRequested)="openSettingsPanel()">
-
-
+
+
-
+
+
+
+
+
@if (optionsPanelOpen() && isMobile()) {
();
private noteIndexService = inject(NoteIndexService);
+ private vaultService = inject(VaultService);
+ private graphSettingsService = inject(GraphSettingsService);
optionsPanelOpen = signal(false);
+ settingsPanelOpen = signal(false);
- graphData = computed
(() => {
+ // Base graph data from note index
+ private baseGraphData = computed(() => {
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({
- 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(() => {
+ 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(() => {
+ 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;
}
diff --git a/src/components/graph-view/graph-view.component.ts b/src/components/graph-view/graph-view.component.ts
index efa0bf5..e33ebed 100644
--- a/src/components/graph-view/graph-view.component.ts
+++ b/src/components/graph-view/graph-view.component.ts
@@ -28,6 +28,7 @@ export interface GraphDisplayOptions {
linkDistance: number;
centerStrength: number;
centerNodeId?: string;
+ nodeColors?: Map; // 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)">
@@ -101,6 +103,22 @@ export interface GraphDisplayOptions {
Nodes: {{ simulatedNodes().length }}
Links: {{ edges().length }}
+
+
+
`,
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
();
nodeHovered = output();
+ settingsRequested = output();
svg = viewChild.required>('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
+ }
}
diff --git a/src/components/search-bar/search-bar.component.ts b/src/components/search-bar/search-bar.component.ts
new file mode 100644
index 0000000..181e4a6
--- /dev/null
+++ b/src/components/search-bar/search-bar.component.ts
@@ -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: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ 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();
+
+ @ViewChild('searchInput') searchInputRef?: ElementRef;
+ @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) {}
+
+ 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;
+ }
+ }
+}
diff --git a/src/components/search-input-with-assistant/search-input-with-assistant.component.ts b/src/components/search-input-with-assistant/search-input-with-assistant.component.ts
new file mode 100644
index 0000000..b3aa62f
--- /dev/null
+++ b/src/components/search-input-with-assistant/search-input-with-assistant.component.ts
@@ -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: `
+
+ `,
+ 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();
+ @Output() submit = new EventEmitter();
+
+ @ViewChild('searchInput') searchInputRef?: ElementRef;
+ @ViewChild('assistant') assistantRef?: SearchQueryAssistantComponent;
+
+ anchorElement: HTMLElement | null = null;
+ private historyService = inject(SearchHistoryService);
+
+ constructor(private hostElement: ElementRef) {}
+
+ 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();
+ }
+ }
+}
diff --git a/src/components/search-panel/search-panel.component.ts b/src/components/search-panel/search-panel.component.ts
new file mode 100644
index 0000000..314d1e3
--- /dev/null
+++ b/src/components/search-panel/search-panel.component.ts
@@ -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: `
+
+
+
+
+
+
+ @if (isSearching()) {
+
+ } @else if (hasSearched() && results().length === 0) {
+
+
+
No results found
+
Try adjusting your search query
+
+ } @else if (results().length > 0) {
+
+ } @else {
+
+
+
Start typing to search
+
+ }
+
+
+ `,
+ 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([]);
+ 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: {}
+ });
+ }
+ }
+}
diff --git a/src/components/search-query-assistant/search-query-assistant.component.ts b/src/components/search-query-assistant/search-query-assistant.component.ts
new file mode 100644
index 0000000..70978d6
--- /dev/null
+++ b/src/components/search-query-assistant/search-query-assistant.component.ts
@@ -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: `
+
+
+ @if (isOpen()) {
+
+
+
+
+
Search options
+
+
+
+ @if (showHelp) {
+
+
Operators:
+
+
OR
Either term
+
-term
Exclude
+
"phrase"
Exact match
+
term*
Wildcard
+
+
Combine: tag:#todo OR tag:#urgent
+
+ }
+
+
+
+
+
+
+
+ @if (history().length > 0) {
+
+
+
History
+
+
+
+
+
+
+ }
+
+
+ @if (suggestions().length > 0) {
+
+
+ {{ suggestionsTitle() }}
+
+
+
+
+
+ }
+
+ }
+
+ `,
+ 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();
+ @Output() querySubmit = new EventEmitter();
+
+ @ViewChild('popover') popoverRef?: ElementRef;
+
+ isOpen = signal(false);
+ showHelp = false;
+ selectedIndex = signal(-1);
+ navigationItems = signal([]);
+ history = signal([]);
+
+ private historyService = inject(SearchHistoryService);
+ private assistantService = inject(SearchAssistantService);
+ private elementRef = inject(ElementRef);
+
+ searchOptions = signal([]);
+ // 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([]);
+
+ /**
+ * Title for suggestions section
+ */
+ suggestionsTitle = signal('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;
+ }
+}
diff --git a/src/components/search-results/search-results.component.ts b/src/components/search-results/search-results.component.ts
new file mode 100644
index 0000000..5345340
--- /dev/null
+++ b/src/components/search-results/search-results.component.ts
@@ -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: `
+
+
+
+
+
+ {{ totalResults() }} {{ totalResults() === 1 ? 'result' : 'results' }}
+ @if (totalMatches() > 0) {
+
+ ({{ totalMatches() }} {{ totalMatches() === 1 ? 'match' : 'matches' }})
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @if (sortedGroups().length === 0) {
+
+
+
No results found
+
+ } @else {
+
+ @for (group of sortedGroups(); track group.noteId) {
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ group.fileName }}
+
+
+ {{ group.filePath }}
+
+
+
+
+
+ {{ group.matchCount }} {{ group.matchCount === 1 ? 'match' : 'matches' }}
+
+
+
+
+
+
+
+
+ @if (group.isExpanded) {
+
+ @for (match of group.matches; track $index) {
+
+
+
+
+ {{ match.type }}
+
+ @if (match.line) {
+
+ Line {{ match.line }}
+
+ }
+
+
+
+
+
+
+
+ }
+
+ }
+
+ }
+
+ }
+
+
+ `,
+ 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([]);
+ 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,
+ '$1'
+ );
+ }
+
+ /**
+ * Escape HTML to prevent XSS
+ */
+ private escapeHtml(text: string): string {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+}
diff --git a/src/core/graph/graph-index.service.ts b/src/core/graph/graph-index.service.ts
new file mode 100644
index 0000000..34c4de4
--- /dev/null
+++ b/src/core/graph/graph-index.service.ts
@@ -0,0 +1,183 @@
+import { Injectable, signal } from '@angular/core';
+
+/**
+ * Index of vault content for graph view
+ * Maintains lists of files, tags, paths, attachments for fast filtering
+ */
+export interface GraphIndexData {
+ /** All files (markdown notes) */
+ files: FileIndexEntry[];
+ /** All unique tags found in the vault */
+ tags: string[];
+ /** All unique paths (folders) */
+ paths: string[];
+ /** All attachments (non-markdown files) */
+ attachments: AttachmentIndexEntry[];
+ /** Map of file ID to tags */
+ fileToTags: Map;
+ /** Map of file ID to path */
+ fileToPath: Map;
+}
+
+export interface FileIndexEntry {
+ id: string;
+ name: string;
+ path: string;
+ tags: string[];
+}
+
+export interface AttachmentIndexEntry {
+ id: string;
+ name: string;
+ path: string;
+ extension: string;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class GraphIndexService {
+ private indexData = signal({
+ files: [],
+ tags: [],
+ paths: [],
+ attachments: [],
+ fileToTags: new Map(),
+ fileToPath: new Map()
+ });
+
+ /**
+ * Current index data as signal
+ */
+ get index() {
+ return this.indexData.asReadonly();
+ }
+
+ /**
+ * Rebuild index from vault data
+ */
+ rebuildIndex(notes: any[]): void {
+ const files: FileIndexEntry[] = [];
+ const tagsSet = new Set();
+ const pathsSet = new Set();
+ const attachments: AttachmentIndexEntry[] = [];
+ const fileToTags = new Map();
+ const fileToPath = new Map();
+
+ for (const note of notes) {
+ // Extract path components
+ const filePath = note.filePath || note.path || '';
+ const pathParts = filePath.split('/');
+ const fileName = pathParts[pathParts.length - 1] || '';
+ const folderPath = pathParts.slice(0, -1).join('/');
+
+ // Add to paths
+ if (folderPath) {
+ pathsSet.add(folderPath);
+ // Also add parent paths
+ const parentParts = folderPath.split('/');
+ for (let i = 1; i <= parentParts.length; i++) {
+ pathsSet.add(parentParts.slice(0, i).join('/'));
+ }
+ }
+
+ // Check if it's an attachment (non-markdown file)
+ const isAttachment = this.isAttachmentFile(fileName);
+
+ if (isAttachment) {
+ const extension = this.getFileExtension(fileName);
+ attachments.push({
+ id: note.id,
+ name: fileName,
+ path: filePath,
+ extension
+ });
+ } else {
+ // Regular markdown file
+ const tags = note.tags || [];
+
+ // Add tags to set (normalize to include #)
+ tags.forEach((tag: string) => {
+ const normalized = tag.startsWith('#') ? tag : `#${tag}`;
+ tagsSet.add(normalized);
+ });
+
+ files.push({
+ id: note.id,
+ name: fileName,
+ path: filePath,
+ tags
+ });
+
+ fileToTags.set(note.id, tags);
+ }
+
+ fileToPath.set(note.id, filePath);
+ }
+
+ // Update index
+ this.indexData.set({
+ files,
+ tags: Array.from(tagsSet).sort(),
+ paths: Array.from(pathsSet).sort(),
+ attachments,
+ fileToTags,
+ fileToPath
+ });
+ }
+
+ /**
+ * Get suggestions based on query type
+ */
+ getSuggestions(type: 'path' | 'tag' | 'file' | 'property', filter?: string): string[] {
+ const index = this.indexData();
+ const filterLower = filter?.toLowerCase() || '';
+
+ switch (type) {
+ case 'path':
+ return index.paths.filter(p => !filter || p.toLowerCase().includes(filterLower));
+
+ case 'tag':
+ return index.tags.filter(t => !filter || t.toLowerCase().includes(filterLower));
+
+ case 'file':
+ return index.files
+ .filter(f => !filter || f.name.toLowerCase().includes(filterLower))
+ .map(f => f.name)
+ .slice(0, 50); // Limit to 50 suggestions
+
+ case 'property':
+ // Common Obsidian properties
+ const commonProps = ['title', 'tags', 'aliases', 'description', 'author', 'date', 'created', 'modified'];
+ return commonProps.filter(p => !filter || p.toLowerCase().includes(filterLower));
+
+ default:
+ return [];
+ }
+ }
+
+ /**
+ * Check if file is an attachment (non-markdown)
+ */
+ private isAttachmentFile(fileName: string): boolean {
+ const attachmentExtensions = [
+ 'pdf', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp',
+ 'mp4', 'webm', 'ogv', 'mov', 'mkv',
+ 'mp3', 'wav', 'ogg', 'm4a', 'flac',
+ 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
+ 'zip', 'rar', '7z', 'tar', 'gz'
+ ];
+
+ const ext = this.getFileExtension(fileName);
+ return attachmentExtensions.includes(ext);
+ }
+
+ /**
+ * Get file extension (lowercase, no dot)
+ */
+ private getFileExtension(fileName: string): string {
+ const lastDot = fileName.lastIndexOf('.');
+ if (lastDot === -1) return '';
+ return fileName.substring(lastDot + 1).toLowerCase();
+ }
+}
diff --git a/src/core/search/README.md b/src/core/search/README.md
new file mode 100644
index 0000000..12d1207
--- /dev/null
+++ b/src/core/search/README.md
@@ -0,0 +1,221 @@
+# Search Module
+
+## Quick Start
+
+### Basic Usage
+
+```typescript
+import { SearchPanelComponent } from './components/search-panel/search-panel.component';
+
+// In your component template
+
+```
+
+### Standalone Search Bar
+
+```typescript
+import { SearchBarComponent } from './components/search-bar/search-bar.component';
+
+
+```
+
+## Supported Operators
+
+| Operator | Description | Example |
+|----------|-------------|---------|
+| `file:` | Match file name | `file:.jpg` |
+| `path:` | Match file path | `path:"Daily notes"` |
+| `content:` | Match in content | `content:"hello"` |
+| `tag:` | Search tags | `tag:#work` |
+| `line:` | Same line | `line:(mix flour)` |
+| `block:` | Same block | `block:(dog cat)` |
+| `section:` | Same section | `section:(intro)` |
+| `task:` | In tasks | `task:call` |
+| `task-todo:` | Uncompleted tasks | `task-todo:review` |
+| `task-done:` | Completed tasks | `task-done:meeting` |
+| `match-case:` | Case-sensitive | `match-case:API` |
+| `ignore-case:` | Case-insensitive | `ignore-case:test` |
+| `[property]` | Property exists | `[description]` |
+| `[property:value]` | Property value | `[status]:"draft"` |
+
+## Boolean Operators
+
+- **AND** (implicit): `term1 term2`
+- **OR**: `term1 OR term2`
+- **NOT**: `-term`
+- **Grouping**: `(term1 OR term2) term3`
+
+## Special Syntax
+
+- **Exact phrase**: `"hello world"`
+- **Wildcard**: `test*`
+- **Regex**: `/\d{4}/`
+
+## Services
+
+### SearchIndexService
+
+Indexes vault content for fast searching.
+
+```typescript
+constructor(private searchIndex: SearchIndexService) {}
+
+// Rebuild index
+this.searchIndex.rebuildIndex(notes);
+
+// Get suggestions
+const suggestions = this.searchIndex.getSuggestions('tag', '#');
+```
+
+### SearchEvaluatorService
+
+Executes search queries.
+
+```typescript
+constructor(private evaluator: SearchEvaluatorService) {}
+
+// Search
+const results = this.evaluator.search('tag:#work', {
+ caseSensitive: false
+});
+```
+
+### SearchAssistantService
+
+Provides autocomplete and suggestions.
+
+```typescript
+constructor(private assistant: SearchAssistantService) {}
+
+// Get filtered options
+const options = this.assistant.getFilteredOptions('pa');
+
+// Get contextual suggestions
+const suggestions = this.assistant.getSuggestions('path:proj');
+```
+
+### SearchHistoryService
+
+Manages search history.
+
+```typescript
+constructor(private history: SearchHistoryService) {}
+
+// Add to history
+this.history.add('vault', 'my query');
+
+// Get history
+const items = this.history.list('vault');
+
+// Clear history
+this.history.clear('vault');
+```
+
+## Components
+
+### SearchBarComponent
+
+Main search input with Aa and .* buttons.
+
+**Inputs:**
+- `placeholder: string` - Placeholder text
+- `context: string` - History context
+- `showSearchIcon: boolean` - Show search icon
+- `inputClass: string` - Custom CSS classes
+- `initialQuery: string` - Initial query value
+
+**Outputs:**
+- `search: { query: string; options: SearchOptions }` - Search event
+- `queryChange: string` - Query change event
+
+### SearchResultsComponent
+
+Displays search results with grouping.
+
+**Inputs:**
+- `results: SearchResult[]` - Search results
+
+**Outputs:**
+- `noteOpen: { noteId: string; line?: number }` - Note open event
+
+### SearchPanelComponent
+
+Complete search UI (bar + results).
+
+**Inputs:**
+- `placeholder: string` - Placeholder text
+- `context: string` - History context
+
+**Outputs:**
+- `noteOpen: { noteId: string; line?: number }` - Note open event
+
+## Examples
+
+### Complex Query
+
+```typescript
+const query = `
+ path:projects/
+ tag:#active
+ (Python OR JavaScript)
+ -deprecated
+ file:".md"
+ match-case:"API"
+`;
+
+const results = this.evaluator.search(query, {
+ caseSensitive: false,
+ regexMode: false
+});
+```
+
+### Custom Search Context
+
+```typescript
+import { SearchContext } from './search-parser.types';
+
+const context: SearchContext = {
+ filePath: 'notes/test.md',
+ fileName: 'test',
+ fileNameWithExt: 'test.md',
+ content: 'Hello world',
+ tags: ['#test'],
+ properties: { status: 'draft' },
+ lines: ['Hello world'],
+ blocks: ['Hello world'],
+ sections: [{ heading: 'Title', content: 'Hello world', level: 1 }],
+ tasks: [{ text: 'Do something', completed: false, line: 1 }]
+};
+
+const predicate = queryToPredicate(parsed);
+const matches = predicate(context);
+```
+
+## Performance Tips
+
+1. **Debounce input**: Avoid searching on every keystroke
+2. **Limit results**: Cap results at reasonable number (e.g., 100)
+3. **Incremental indexing**: Update index incrementally instead of full rebuild
+4. **Lazy loading**: Load results as user scrolls
+
+## Testing
+
+```typescript
+import { parseSearchQuery } from './search-parser';
+
+describe('Search Parser', () => {
+ it('should parse file operator', () => {
+ const parsed = parseSearchQuery('file:test.md');
+ expect(parsed.isEmpty).toBe(false);
+ // Assert AST structure
+ });
+});
+```
diff --git a/src/core/search/search-assistant.service.ts b/src/core/search/search-assistant.service.ts
new file mode 100644
index 0000000..65c12f6
--- /dev/null
+++ b/src/core/search/search-assistant.service.ts
@@ -0,0 +1,269 @@
+import { Injectable, inject } from '@angular/core';
+import { detectQueryType } from './search-parser';
+import { SearchIndexService } from './search-index.service';
+
+/**
+ * Search option definition
+ */
+export interface SearchOption {
+ prefix: string;
+ description: string;
+ example?: string;
+ category: 'field' | 'scope' | 'modifier' | 'property';
+}
+
+/**
+ * Search assistant service
+ * Provides intelligent suggestions and autocomplete for search queries
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class SearchAssistantService {
+ private searchIndex = inject(SearchIndexService);
+
+ /**
+ * All available search options
+ */
+ private readonly allOptions: SearchOption[] = [
+ // Field operators
+ { prefix: 'file:', description: 'Match in file name', example: 'file:.jpg', category: 'field' },
+ { prefix: 'path:', description: 'Match in file path', example: 'path:"Daily notes"', category: 'field' },
+ { prefix: 'content:', description: 'Match in content', example: 'content:"happy cat"', category: 'field' },
+ { prefix: 'tag:', description: 'Search for tags', example: 'tag:#work', category: 'field' },
+
+ // Scope operators
+ { prefix: 'line:', description: 'Keywords on same line', example: 'line:(mix flour)', category: 'scope' },
+ { prefix: 'block:', description: 'Keywords in same block', example: 'block:(dog cat)', category: 'scope' },
+ { prefix: 'section:', description: 'Keywords under same heading', example: 'section:(dog cat)', category: 'scope' },
+
+ // Task operators
+ { prefix: 'task:', description: 'Search in tasks', example: 'task:call', category: 'field' },
+ { prefix: 'task-todo:', description: 'Search uncompleted tasks', example: 'task-todo:call', category: 'field' },
+ { prefix: 'task-done:', description: 'Search completed tasks', example: 'task-done:call', category: 'field' },
+
+ // Case modifiers
+ { prefix: 'match-case:', description: 'Case-sensitive search', example: 'match-case:HappyCat', category: 'modifier' },
+ { prefix: 'ignore-case:', description: 'Case-insensitive search', example: 'ignore-case:ikea', category: 'modifier' },
+
+ // Property
+ { prefix: '[property]', description: 'Match frontmatter property', example: '[description]:"page"', category: 'property' }
+ ];
+
+ /**
+ * Get filtered search options based on current input
+ */
+ getFilteredOptions(query: string): SearchOption[] {
+ if (!query || !query.trim()) {
+ return this.allOptions;
+ }
+
+ const lastToken = this.getLastToken(query);
+ if (!lastToken) {
+ return this.allOptions;
+ }
+
+ const lowerToken = lastToken.toLowerCase();
+
+ // Filter options that start with or contain the token
+ return this.allOptions.filter(option => {
+ const prefix = option.prefix.toLowerCase();
+ return prefix.startsWith(lowerToken) || prefix.includes(lowerToken);
+ });
+ }
+
+ /**
+ * Get contextual suggestions based on the current query
+ */
+ getSuggestions(query: string): { type: string; items: string[] } {
+ const detected = detectQueryType(query);
+
+ switch (detected.type) {
+ case 'path':
+ return {
+ type: 'Paths',
+ items: this.searchIndex.getSuggestions('path', detected.value)
+ };
+
+ case 'file':
+ return {
+ type: 'Files',
+ items: this.searchIndex.getSuggestions('file', detected.value)
+ };
+
+ case 'tag':
+ return {
+ type: 'Tags',
+ items: this.searchIndex.getSuggestions('tag', detected.value)
+ };
+
+ case 'section':
+ return {
+ type: 'Headings',
+ items: this.searchIndex.getSuggestions('section', detected.value)
+ };
+
+ case 'task':
+ case 'task-todo':
+ case 'task-done':
+ return {
+ type: 'Task keywords',
+ items: this.searchIndex.getSuggestions('task', detected.value)
+ };
+
+ case 'property':
+ if (detected.prefix) {
+ // Suggesting property values
+ return {
+ type: `Values for "${detected.prefix}"`,
+ items: this.searchIndex.getSuggestions('property', detected.value, detected.prefix)
+ };
+ } else {
+ // Suggesting property keys
+ return {
+ type: 'Properties',
+ items: this.searchIndex.getSuggestions('property', detected.value)
+ };
+ }
+
+ default:
+ return { type: '', items: [] };
+ }
+ }
+
+ /**
+ * Insert an option into the query at the cursor position
+ */
+ insertOption(query: string, option: string, cursorPosition: number): { newQuery: string; newCursorPosition: number } {
+ const lastToken = this.getLastToken(query);
+
+ if (!lastToken) {
+ // Insert at cursor
+ const before = query.substring(0, cursorPosition);
+ const after = query.substring(cursorPosition);
+ const newQuery = before + option + after;
+ return {
+ newQuery,
+ newCursorPosition: cursorPosition + option.length
+ };
+ }
+
+ // Replace last token
+ const lastTokenIndex = query.lastIndexOf(lastToken);
+ const before = query.substring(0, lastTokenIndex);
+ const after = query.substring(lastTokenIndex + lastToken.length);
+ const newQuery = before + option + after;
+
+ return {
+ newQuery,
+ newCursorPosition: before.length + option.length
+ };
+ }
+
+ /**
+ * Insert a suggestion value into the query
+ */
+ insertSuggestion(query: string, suggestion: string): string {
+ const detected = detectQueryType(query);
+
+ // Add quotes if the suggestion contains spaces
+ const needsQuotes = suggestion.includes(' ') || suggestion.includes('/');
+ const quotedSuggestion = needsQuotes ? `"${suggestion}"` : suggestion;
+
+ switch (detected.type) {
+ case 'path':
+ return `path:${quotedSuggestion}`;
+
+ case 'file':
+ return `file:${quotedSuggestion}`;
+
+ case 'tag':
+ // Ensure tag starts with #
+ const tagValue = suggestion.startsWith('#') ? suggestion : `#${suggestion}`;
+ return `tag:${tagValue}`;
+
+ case 'section':
+ return `section:${quotedSuggestion}`;
+
+ case 'task':
+ return `task:${quotedSuggestion}`;
+
+ case 'task-todo':
+ return `task-todo:${quotedSuggestion}`;
+
+ case 'task-done':
+ return `task-done:${quotedSuggestion}`;
+
+ case 'property':
+ if (detected.prefix) {
+ // Inserting property value
+ return `[${detected.prefix}:${quotedSuggestion}]`;
+ } else {
+ // Inserting property key
+ return `[${suggestion}]`;
+ }
+
+ default:
+ return suggestion;
+ }
+ }
+
+ /**
+ * Get the last token from the query
+ */
+ private getLastToken(query: string): string {
+ const trimmed = query.trim();
+ const tokens = trimmed.split(/\s+/);
+ return tokens[tokens.length - 1] || '';
+ }
+
+ /**
+ * Get help text for a specific operator
+ */
+ getHelpText(operator: string): string {
+ const option = this.allOptions.find(o => o.prefix === operator);
+ if (option) {
+ return `${option.description}${option.example ? ` (e.g., ${option.example})` : ''}`;
+ }
+ return '';
+ }
+
+ /**
+ * Validate a search query
+ */
+ validateQuery(query: string): { valid: boolean; errors: string[] } {
+ const errors: string[] = [];
+
+ // Check for unmatched quotes
+ const quoteCount = (query.match(/"/g) || []).length;
+ if (quoteCount % 2 !== 0) {
+ errors.push('Unmatched quote');
+ }
+
+ // Check for unmatched parentheses
+ let parenDepth = 0;
+ for (const char of query) {
+ if (char === '(') parenDepth++;
+ if (char === ')') parenDepth--;
+ if (parenDepth < 0) {
+ errors.push('Unmatched closing parenthesis');
+ break;
+ }
+ }
+ if (parenDepth > 0) {
+ errors.push('Unmatched opening parenthesis');
+ }
+
+ // Check for unmatched brackets
+ const openBrackets = (query.match(/\[/g) || []).length;
+ const closeBrackets = (query.match(/\]/g) || []).length;
+ if (openBrackets !== closeBrackets) {
+ errors.push('Unmatched bracket');
+ }
+
+ return {
+ valid: errors.length === 0,
+ errors
+ };
+ }
+}
diff --git a/src/core/search/search-evaluator.service.ts b/src/core/search/search-evaluator.service.ts
new file mode 100644
index 0000000..d0fca60
--- /dev/null
+++ b/src/core/search/search-evaluator.service.ts
@@ -0,0 +1,200 @@
+import { Injectable, inject } from '@angular/core';
+import { parseSearchQuery, queryToPredicate } from './search-parser';
+import { SearchOptions } from './search-parser.types';
+import { SearchIndexService } from './search-index.service';
+
+/**
+ * Result of a search query
+ */
+export interface SearchResult {
+ noteId: string;
+ matches: SearchMatch[];
+ score: number;
+}
+
+/**
+ * Individual match within a note
+ */
+export interface SearchMatch {
+ type: 'content' | 'heading' | 'task' | 'property';
+ text: string;
+ context: string;
+ line?: number;
+ startOffset?: number;
+ endOffset?: number;
+}
+
+/**
+ * Search evaluator service
+ * Executes search queries against the indexed vault
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class SearchEvaluatorService {
+ private searchIndex = inject(SearchIndexService);
+
+ /**
+ * Execute a search query and return matching notes
+ */
+ search(query: string, options?: SearchOptions): SearchResult[] {
+ if (!query || !query.trim()) {
+ return [];
+ }
+
+ // Parse the query into an AST
+ const parsed = parseSearchQuery(query, options);
+ if (parsed.isEmpty) {
+ return [];
+ }
+
+ // Convert to predicate function
+ const predicate = queryToPredicate(parsed, options);
+
+ // Evaluate against all indexed contexts
+ const results: SearchResult[] = [];
+ const allContexts = this.searchIndex.getAllContexts();
+
+ for (const context of allContexts) {
+ if (predicate(context)) {
+ // Find matches within the content
+ const matches = this.findMatches(context, query);
+ const score = this.calculateScore(context, query, matches);
+
+ results.push({
+ noteId: context.filePath, // Using filePath as noteId
+ matches,
+ score
+ });
+ }
+ }
+
+ // Sort by score (descending)
+ results.sort((a, b) => b.score - a.score);
+
+ return results;
+ }
+
+ /**
+ * Find specific matches within a context
+ */
+ private findMatches(context: any, query: string): SearchMatch[] {
+ const matches: SearchMatch[] = [];
+ const queryLower = query.toLowerCase();
+
+ // Simple keyword extraction (can be enhanced)
+ const keywords = this.extractKeywords(query);
+
+ // Search in content
+ keywords.forEach(keyword => {
+ const keywordLower = keyword.toLowerCase();
+ let index = 0;
+ const contentLower = context.content.toLowerCase();
+
+ while ((index = contentLower.indexOf(keywordLower, index)) !== -1) {
+ const start = Math.max(0, index - 50);
+ const end = Math.min(context.content.length, index + keyword.length + 50);
+ const contextText = context.content.substring(start, end);
+
+ matches.push({
+ type: 'content',
+ text: keyword,
+ context: (start > 0 ? '...' : '') + contextText + (end < context.content.length ? '...' : ''),
+ startOffset: index,
+ endOffset: index + keyword.length
+ });
+
+ index += keyword.length;
+ }
+ });
+
+ // Search in headings
+ context.sections?.forEach((section: any) => {
+ keywords.forEach(keyword => {
+ if (section.heading.toLowerCase().includes(keyword.toLowerCase())) {
+ matches.push({
+ type: 'heading',
+ text: keyword,
+ context: section.heading
+ });
+ }
+ });
+ });
+
+ // Search in tasks
+ context.tasks?.forEach((task: any, index: number) => {
+ keywords.forEach(keyword => {
+ if (task.text.toLowerCase().includes(keyword.toLowerCase())) {
+ matches.push({
+ type: 'task',
+ text: keyword,
+ context: task.text,
+ line: task.line
+ });
+ }
+ });
+ });
+
+ // Limit matches to avoid overwhelming results
+ return matches.slice(0, 10);
+ }
+
+ /**
+ * Extract keywords from query for highlighting
+ */
+ private extractKeywords(query: string): string[] {
+ const keywords: string[] = [];
+
+ // Remove operators and extract actual search terms
+ const cleaned = query
+ .replace(/\b(AND|OR|NOT)\b/gi, '')
+ .replace(/[()]/g, ' ')
+ .replace(/-\w+/g, '') // Remove negated terms
+ .replace(/\w+:/g, ''); // Remove prefixes
+
+ // Extract quoted phrases
+ const quotedMatches = cleaned.match(/"([^"]+)"/g);
+ if (quotedMatches) {
+ quotedMatches.forEach(match => {
+ keywords.push(match.replace(/"/g, ''));
+ });
+ }
+
+ // Extract individual words (longer than 2 chars)
+ const words = cleaned
+ .replace(/"[^"]+"/g, '')
+ .split(/\s+/)
+ .filter(w => w.length > 2);
+
+ keywords.push(...words);
+
+ return Array.from(new Set(keywords)); // Deduplicate
+ }
+
+ /**
+ * Calculate relevance score for a match
+ */
+ private calculateScore(context: any, query: string, matches: SearchMatch[]): number {
+ let score = 0;
+
+ // Base score from number of matches
+ score += matches.length * 10;
+
+ // Bonus for matches in headings
+ const headingMatches = matches.filter(m => m.type === 'heading');
+ score += headingMatches.length * 20;
+
+ // Bonus for matches in file name
+ const queryLower = query.toLowerCase();
+ if (context.fileName.toLowerCase().includes(queryLower)) {
+ score += 50;
+ }
+
+ // Bonus for exact phrase matches
+ if (context.content.toLowerCase().includes(queryLower)) {
+ score += 30;
+ }
+
+ return score;
+ }
+}
diff --git a/src/core/search/search-history.service.ts b/src/core/search/search-history.service.ts
new file mode 100644
index 0000000..1029c08
--- /dev/null
+++ b/src/core/search/search-history.service.ts
@@ -0,0 +1,80 @@
+import { Injectable } from '@angular/core';
+
+/**
+ * Service for managing search history using localStorage
+ * Each search context (vault search, graph search, etc.) has its own history
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class SearchHistoryService {
+ private readonly MAX_HISTORY = 10;
+ private readonly STORAGE_PREFIX = 'obsidian-search-history-';
+
+ /**
+ * Add a query to history for a specific context
+ */
+ add(context: string, query: string): void {
+ if (!query || !query.trim()) {
+ return;
+ }
+
+ const key = this.getStorageKey(context);
+ const history = this.list(context);
+
+ // Remove if already exists (to move to top)
+ const filtered = history.filter(q => q !== query);
+
+ // Add to beginning
+ filtered.unshift(query);
+
+ // Limit to MAX_HISTORY
+ const limited = filtered.slice(0, this.MAX_HISTORY);
+
+ localStorage.setItem(key, JSON.stringify(limited));
+ }
+
+ /**
+ * Get history list for a specific context
+ */
+ list(context: string): string[] {
+ const key = this.getStorageKey(context);
+ const stored = localStorage.getItem(key);
+
+ if (!stored) {
+ return [];
+ }
+
+ try {
+ const parsed = JSON.parse(stored);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+ }
+
+ /**
+ * Clear history for a specific context
+ */
+ clear(context: string): void {
+ const key = this.getStorageKey(context);
+ localStorage.removeItem(key);
+ }
+
+ /**
+ * Remove a specific query from history
+ */
+ remove(context: string, query: string): void {
+ const key = this.getStorageKey(context);
+ const history = this.list(context);
+ const filtered = history.filter(q => q !== query);
+ localStorage.setItem(key, JSON.stringify(filtered));
+ }
+
+ /**
+ * Get storage key for a context
+ */
+ private getStorageKey(context: string): string {
+ return `${this.STORAGE_PREFIX}${context}`;
+ }
+}
diff --git a/src/core/search/search-index.service.ts b/src/core/search/search-index.service.ts
new file mode 100644
index 0000000..ad339fb
--- /dev/null
+++ b/src/core/search/search-index.service.ts
@@ -0,0 +1,329 @@
+import { Injectable, signal, computed, inject } from '@angular/core';
+import { SearchContext, SectionContent, TaskInfo } from './search-parser.types';
+import { VaultService } from '../../services/vault.service';
+import { Note } from '../../types';
+
+/**
+ * Comprehensive search index for the vault
+ * Indexes all content for fast searching with full Obsidian operator support
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class SearchIndexService {
+ private vaultService = inject(VaultService);
+
+ private indexData = signal