chore: update Angular TypeScript build info cache
This commit is contained in:
@@ -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