chore: update Angular TypeScript build info cache
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user