feat: add Meilisearch backend integration with Docker Compose setup and Excalidraw support

This commit is contained in:
2025-10-14 10:41:15 -04:00
parent b359e8ab8e
commit 32a9998b40
71 changed files with 11544 additions and 435 deletions
@@ -47,7 +47,6 @@
[cellTemplate]="dayCellTemplate"
[weekStartsOn]="1"
[events]="[]"
(pointerup)="onDatePointerUp()"
></mwl-calendar-month-view>
</div>
@@ -69,7 +68,8 @@
class="day-cell"
[ngClass]="getCellClasses(day)"
(pointerdown)="onDatePointerDown(day, $event)"
(pointerenter)="onDatePointerEnter(day)"
(pointerenter)="onDatePointerEnter(day, $event)"
(pointerup)="onDatePointerUp(day, $event)"
>
<span class="day-number">{{ day.date | date: 'd' }}</span>
<div class="indicators">
@@ -125,6 +125,8 @@ export class MarkdownCalendarComponent {
return;
}
event.preventDefault();
event.stopPropagation();
const date = this.normalizeDay(day.date);
this.dragAnchor = date;
this.selectedRange.set({ start: date, end: date });
@@ -138,9 +140,12 @@ export class MarkdownCalendarComponent {
this.emitSelectionSummary();
this.requestSearchPanel.emit();
this.refresh$.next();
// Capture pointer to enable drag across cells
(event.target as HTMLElement).setPointerCapture(event.pointerId);
}
onDatePointerEnter(day: CalendarMonthViewDay<Date>): void {
onDatePointerEnter(day: CalendarMonthViewDay<Date>, event: PointerEvent): void {
if (!this.dragAnchor) {
return;
}
@@ -152,12 +157,13 @@ export class MarkdownCalendarComponent {
this.refresh$.next();
}
@HostListener('document:pointerup')
finishRangeSelection(): void {
@HostListener('document:pointerup', ['$event'])
finishRangeSelection(event: PointerEvent): void {
this.finalizeSelection();
}
onDatePointerUp(): void {
onDatePointerUp(day: CalendarMonthViewDay<Date>, event: PointerEvent): void {
event.stopPropagation();
this.finalizeSelection();
}
@@ -87,6 +87,19 @@ import { SearchOptions } from '../../core/search/search-parser.types';
.*
</button>
<!-- Highlight toggle (Hi) -->
<button
type="button"
(click)="toggleHighlight()"
[class.bg-accent]="highlight()"
[class.text-white]="highlight()"
class="btn-standard-xs"
[title]="highlight() ? 'Highlight enabled' : 'Highlight disabled'"
aria-label="Toggle highlight"
>
Hi
</button>
<!-- Clear button -->
<button
*ngIf="query"
@@ -133,6 +146,9 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
@Input() showSearchIcon: boolean = true;
@Input() inputClass: string = 'w-full rounded-full border border-border bg-bg-muted/70 py-2.5 pr-4 text-sm text-text-main placeholder:text-text-muted shadow-subtle focus:outline-none focus:ring-2 focus:ring-ring transition-all dark:border-gray-600 dark:bg-gray-800/80 dark:text-gray-100 dark:placeholder-gray-400 dark:focus:ring-blue-500';
@Input() initialQuery: string = '';
@Input() caseSensitiveDefault: boolean = false;
@Input() regexDefault: boolean = false;
@Input() highlightDefault: boolean = true;
@Output() search = new EventEmitter<{ query: string; options: SearchOptions }>();
@Output() queryChange = new EventEmitter<string>();
@@ -145,6 +161,7 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
query = '';
caseSensitive = signal(false);
regexMode = signal(false);
highlight = signal(true);
anchorElement: HTMLElement | null = null;
private historyIndex = -1;
@@ -154,6 +171,9 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
if (this.initialQuery) {
this.query = this.initialQuery;
}
this.caseSensitive.set(!!this.caseSensitiveDefault);
this.regexMode.set(!!this.regexDefault);
this.highlight.set(this.highlightDefault !== false);
}
/**
@@ -222,7 +242,8 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
query: this.query,
options: {
caseSensitive: this.caseSensitive(),
regexMode: this.regexMode()
regexMode: this.regexMode(),
highlight: this.highlight()
}
});
}
@@ -274,7 +295,8 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
query: this.query,
options: {
caseSensitive: this.caseSensitive(),
regexMode: this.regexMode()
regexMode: this.regexMode(),
highlight: this.highlight()
}
});
}
@@ -292,7 +314,23 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
query: this.query,
options: {
caseSensitive: this.caseSensitive(),
regexMode: this.regexMode()
regexMode: this.regexMode(),
highlight: this.highlight()
}
});
}
}
/** Toggle highlight mode */
toggleHighlight(): void {
this.highlight.update(v => !v);
if (this.query.trim()) {
this.search.emit({
query: this.query,
options: {
caseSensitive: this.caseSensitive(),
regexMode: this.regexMode(),
highlight: this.highlight()
}
});
}
@@ -316,7 +354,8 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
query: '',
options: {
caseSensitive: this.caseSensitive(),
regexMode: this.regexMode()
regexMode: this.regexMode(),
highlight: this.highlight()
}
});
}
@@ -7,13 +7,15 @@ import {
ViewChild,
ElementRef,
ChangeDetectionStrategy,
AfterViewInit
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 { inject } from '@angular/core';
import { SearchHistoryService } from '../../core/search/search-history.service';
import { SearchPreferencesService } from '../../core/search/search-preferences.service';
/**
* Search input with integrated query assistant
@@ -51,16 +53,25 @@ import { SearchHistoryService } from '../../core/search/search-history.service';
[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 class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<!-- Case (Aa) -->
<button type="button" class="btn-standard-xs" [class.bg-accent]="caseSensitive()" [class.text-white]="caseSensitive()" (click)="toggleCase()" title="Case sensitivity">Aa</button>
<!-- Regex (.*) -->
<button type="button" class="btn-standard-xs font-mono" [class.bg-accent]="regexMode()" [class.text-white]="regexMode()" (click)="toggleRegex()" title="Regex mode">.*</button>
<!-- Highlight (Hi) -->
<button type="button" class="btn-standard-xs" [class.bg-accent]="highlight()" [class.text-white]="highlight()" (click)="toggleHighlight()" title="Highlight results">Hi</button>
<!-- Clear -->
<button
*ngIf="value"
(click)="clear()"
class="btn-standard-icon"
aria-label="Clear search"
>
<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>
<app-search-query-assistant
@@ -81,7 +92,7 @@ import { SearchHistoryService } from '../../core/search/search-history.service';
}
`]
})
export class SearchInputWithAssistantComponent implements AfterViewInit {
export class SearchInputWithAssistantComponent implements AfterViewInit, OnInit {
@Input() placeholder: string = 'Search...';
@Input() value: string = '';
@Input() context: string = 'default';
@@ -91,15 +102,29 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
@Output() valueChange = new EventEmitter<string>();
@Output() submit = new EventEmitter<string>();
@Output() optionsChange = new EventEmitter<{ caseSensitive: boolean; regexMode: boolean; highlight: boolean }>();
@ViewChild('searchInput') searchInputRef?: ElementRef<HTMLInputElement>;
@ViewChild('assistant') assistantRef?: SearchQueryAssistantComponent;
anchorElement: HTMLElement | null = null;
private historyService = inject(SearchHistoryService);
private prefs = inject(SearchPreferencesService);
caseSensitive = signal(false);
regexMode = signal(false);
highlight = signal(true);
constructor(private hostElement: ElementRef<HTMLElement>) {}
ngOnInit(): void {
// Load header search defaults
const p = this.prefs.getPreferences('vault-header');
this.caseSensitive.set(!!p.caseSensitive);
this.regexMode.set(!!p.regexMode);
this.highlight.set(p.highlight !== false);
}
ngAfterViewInit(): void {
this.anchorElement = this.hostElement.nativeElement;
}
@@ -150,6 +175,7 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
this.historyService.add(this.context, this.value);
}
this.submit.emit(this.value);
this.emitOptionsChange();
if (this.assistantRef) {
this.assistantRef.refreshHistoryView();
this.assistantRef.close();
@@ -194,6 +220,7 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
this.historyService.add(this.context, query);
}
this.submit.emit(query);
this.emitOptionsChange();
if (this.searchInputRef) {
this.searchInputRef.nativeElement.value = query;
@@ -217,6 +244,7 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
this.searchInputRef.nativeElement.value = '';
this.searchInputRef.nativeElement.focus();
}
this.emitOptionsChange();
}
/**
@@ -227,4 +255,30 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
this.searchInputRef.nativeElement.focus();
}
}
private emitOptionsChange(): void {
this.optionsChange.emit({
caseSensitive: this.caseSensitive(),
regexMode: this.regexMode(),
highlight: this.highlight()
});
}
toggleCase(): void {
this.caseSensitive.update(v => !v);
this.prefs.togglePreference('vault-header', 'caseSensitive');
this.emitOptionsChange();
}
toggleRegex(): void {
this.regexMode.update(v => !v);
this.prefs.togglePreference('vault-header', 'regexMode');
this.emitOptionsChange();
}
toggleHighlight(): void {
this.highlight.update(v => !v);
this.prefs.togglePreference('vault-header', 'highlight');
this.emitOptionsChange();
}
}
@@ -6,12 +6,17 @@ import {
signal,
computed,
OnInit,
OnDestroy,
OnChanges,
SimpleChanges,
inject,
ChangeDetectionStrategy,
effect,
untracked
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { isObservable, Subject } from 'rxjs';
import { take, debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { FormsModule } from '@angular/forms';
import { SearchBarComponent } from '../search-bar/search-bar.component';
import { SearchResultsComponent } from '../search-results/search-results.component';
@@ -21,6 +26,8 @@ import { SearchPreferencesService } from '../../core/search/search-preferences.s
import { SearchOptions } from '../../core/search/search-parser.types';
import { VaultService } from '../../services/vault.service';
import { ClientLoggingService } from '../../services/client-logging.service';
import { environment } from '../../core/logging/environment';
import { parseSearchQuery } from '../../core/search/search-parser';
/**
* Complete search panel with bar and results
@@ -38,13 +45,17 @@ import { ClientLoggingService } from '../../services/client-logging.service';
<app-search-bar
[placeholder]="placeholder"
[context]="context"
[initialQuery]="initialQuery"
[showSearchIcon]="true"
[showExamples]="false"
[caseSensitiveDefault]="caseSensitive"
[regexDefault]="regexMode"
[highlightDefault]="highlightEnabled"
(search)="onSearch($event)"
(queryChange)="onQueryChange($event)"
/>
</div>
<!-- Search options toggles -->
@if (hasSearched() && results().length > 0) {
<div class="flex flex-col gap-3 p-4 border-b border-border dark:border-gray-700 bg-bg-muted dark:bg-gray-800">
@@ -94,6 +105,17 @@ import { ClientLoggingService } from '../../services/client-logging.service';
</label>
</div>
}
<!-- Explain search terms panel -->
@if (explainSearchTerms && currentQuery().trim()) {
<div class="p-4 border-b border-border dark:border-gray-700 bg-bg-primary/60 dark:bg-gray-900/60">
<h4 class="text-xs font-semibold uppercase tracking-wide text-text-muted mb-2">Match all of:</h4>
<ul class="ml-1 space-y-1 text-xs">
@for (line of explanationLines(); track $index) {
<li class="text-text-muted dark:text-gray-400">{{ line }}</li>
}
</ul>
</div>
}
<!-- Search results -->
<div class="flex-1 overflow-hidden">
@@ -118,6 +140,7 @@ import { ClientLoggingService } from '../../services/client-logging.service';
[collapseAll]="collapseResults"
[showMoreContext]="showMoreContext"
[contextLines]="contextLines()"
[highlight]="lastOptions.highlight !== false"
(noteOpen)="onNoteOpen($event)"
/>
} @else {
@@ -138,11 +161,14 @@ import { ClientLoggingService } from '../../services/client-logging.service';
}
`]
})
export class SearchPanelComponent implements OnInit {
export class SearchPanelComponent implements OnInit, OnDestroy, OnChanges {
@Input() placeholder: string = 'Search in vault...';
@Input() context: string = 'vault';
@Input() initialQuery: string = '';
@Output() noteOpen = new EventEmitter<{ noteId: string; line?: number }>();
@Output() searchTermChange = new EventEmitter<string>();
@Output() optionsChange = new EventEmitter<SearchOptions>();
private orchestrator = inject(SearchOrchestratorService);
private searchIndex = inject(SearchIndexService);
@@ -156,30 +182,68 @@ export class SearchPanelComponent implements OnInit {
currentQuery = signal('');
private lastOptions: SearchOptions = {};
private searchSubject = new Subject<{ query: string; options?: SearchOptions }>();
// UI toggles
collapseResults = false;
showMoreContext = false;
explainSearchTerms = false;
caseSensitive = false;
regexMode = false;
highlightEnabled = true;
// Computed context lines based on showMoreContext
contextLines = computed(() => this.showMoreContext ? 5 : 2);
private syncIndexEffect = effect(() => {
const notes = this.vaultService.allNotes();
this.logger.info('SearchPanel', 'Detected notes change, rebuilding index', {
context: this.context,
noteCount: notes.length
});
this.searchIndex.rebuildIndex(notes);
// Build explanation lines from the local parser (UI-only, independent of provider)
explanationLines = computed(() => {
const q = this.currentQuery().trim();
if (!q) return [] as string[];
try {
const parsed = parseSearchQuery(q, { caseSensitive: false, regexMode: false });
const lines: string[] = [];
const f = parsed.diagnostics?.filters;
if (f) {
if (f.file?.length) {
f.file.forEach(v => lines.push(`Match file name: "${v}"`));
}
if (f.path?.length) {
f.path.forEach(v => lines.push(`Match path: "${v}"`));
}
if (f.tag?.length) {
f.tag.forEach(v => lines.push(`Match tag: #${v}`));
}
}
// Remaining tokens (rough): use diagnostics.tokens minus operators
const tokens = parsed.diagnostics?.tokens ?? [];
tokens
.filter(t => t.toUpperCase() !== 'AND' && t.toUpperCase() !== 'OR')
.filter(t => !/^\w+:/.test(t))
.forEach(t => lines.push(`Matches text: "${t.replace(/^"|"$/g,'')}"`));
return lines;
} catch {
return [] as string[];
}
});
const query = untracked(() => this.currentQuery());
if (query && query.trim()) {
this.logger.debug('SearchPanel', 'Re-running search after index rebuild', {
query,
context: this.context
private syncIndexEffect = effect(() => {
// Only rebuild index if not using Meilisearch
if (!environment.USE_MEILI) {
const notes = this.vaultService.allNotes();
this.logger.info('SearchPanel', 'Detected notes change, rebuilding index', {
context: this.context,
noteCount: notes.length
});
this.executeSearch(query);
this.searchIndex.rebuildIndex(notes);
const query = untracked(() => this.currentQuery());
if (query && query.trim()) {
this.logger.debug('SearchPanel', 'Re-running search after index rebuild', {
query,
context: this.context
});
this.executeSearch(query);
}
}
}, { allowSignalWrites: true });
@@ -189,6 +253,40 @@ export class SearchPanelComponent implements OnInit {
this.collapseResults = prefs.collapseResults;
this.showMoreContext = prefs.showMoreContext;
this.explainSearchTerms = prefs.explainSearchTerms;
this.caseSensitive = prefs.caseSensitive;
this.regexMode = prefs.regexMode;
this.highlightEnabled = prefs.highlight;
// Setup debounced search for live typing (only when using Meilisearch)
if (environment.USE_MEILI) {
this.searchSubject.pipe(
debounceTime(300),
distinctUntilChanged((prev, curr) => prev.query === curr.query)
).subscribe(({ query, options }) => {
this.executeSearch(query, options);
});
}
// Execute initial query if provided
const iq = this.initialQuery?.trim();
if (iq && iq.length >= 2) {
this.currentQuery.set(iq);
this.executeSearch(iq);
}
}
ngOnDestroy(): void {
this.searchSubject.complete();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['initialQuery']) {
const iq = (this.initialQuery ?? '').trim();
if (iq && iq !== this.currentQuery()) {
this.currentQuery.set(iq);
this.executeSearch(iq);
}
}
}
/**
@@ -218,33 +316,72 @@ export class SearchPanelComponent implements OnInit {
query: trimmed,
options: baseOptions,
contextLines: this.contextLines(),
context: this.context
context: this.context,
useMeilisearch: environment.USE_MEILI
});
this.isSearching.set(true);
setTimeout(() => {
// With Meilisearch, execute immediately (no setTimeout needed)
// Without Meilisearch, use setTimeout to avoid blocking UI
const executeNow = () => {
try {
const searchResults = this.orchestrator.execute(trimmed, {
const exec = this.orchestrator.execute(trimmed, {
...baseOptions,
contextLines: this.contextLines()
});
this.logger.info('SearchPanel', 'Search completed', {
query: trimmed,
resultCount: searchResults.length
});
this.results.set(searchResults);
this.hasSearched.set(true);
if (isObservable(exec)) {
exec.pipe(take(1)).subscribe({
next: (arr) => {
this.logger.info('SearchPanel', 'Search completed (obs)', {
query: trimmed,
resultCount: arr.length,
firstResult: arr.length > 0 ? { noteId: arr[0].noteId, matchCount: arr[0].matches.length } : null
});
console.log('[SearchPanel] Results received:', arr);
this.results.set(arr);
this.hasSearched.set(true);
this.isSearching.set(false);
},
error: (e) => {
this.logger.error('SearchPanel', 'Search execution error (obs)', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined
});
console.error('[SearchPanel] Search error:', e);
this.results.set([]);
this.hasSearched.set(true);
this.isSearching.set(false);
}
});
} else {
const searchResults = exec;
this.logger.info('SearchPanel', 'Search completed', {
query: trimmed,
resultCount: searchResults.length
});
this.results.set(searchResults);
this.hasSearched.set(true);
this.isSearching.set(false);
}
} catch (error) {
this.logger.error('SearchPanel', 'Search execution error', {
error: error instanceof Error ? error.message : String(error)
});
this.results.set([]);
this.hasSearched.set(true);
} finally {
this.isSearching.set(false);
}
}, 0);
};
if (environment.USE_MEILI) {
// Execute immediately with Meilisearch (backend handles it)
executeNow();
} else {
// Use setTimeout to avoid blocking UI with local search
setTimeout(executeNow, 0);
}
}
/**
@@ -255,6 +392,15 @@ export class SearchPanelComponent implements OnInit {
this.currentQuery.set(query);
this.lastOptions = { ...options };
// Persist user option toggles
this.preferences.updatePreferences(this.context, {
caseSensitive: !!options.caseSensitive,
regexMode: !!options.regexMode,
highlight: options.highlight !== false
});
// Emit upwards for header to sync highlight to document
this.searchTermChange.emit(query);
this.optionsChange.emit({ ...options });
this.executeSearch(query, options);
}
@@ -264,8 +410,14 @@ export class SearchPanelComponent implements OnInit {
onQueryChange(query: string): void {
this.currentQuery.set(query);
// Could implement debounced live search here
// For now, only search on Enter
// With Meilisearch, enable live search with debounce
if (environment.USE_MEILI && query.trim().length >= 2) {
this.searchSubject.next({ query, options: this.lastOptions });
} else if (!query.trim()) {
// Clear results if query is empty
this.results.set([]);
this.hasSearched.set(false);
}
}
/**
@@ -294,6 +446,8 @@ export class SearchPanelComponent implements OnInit {
this.preferences.updatePreferences(this.context, {
collapseResults: this.collapseResults
});
// No need to re-run search, just update the preference
// The search-results component will react to the input change
}
/**
@@ -306,7 +460,7 @@ export class SearchPanelComponent implements OnInit {
// Re-run search with new context lines
if (this.currentQuery()) {
this.executeSearch(this.currentQuery());
this.executeSearch(this.currentQuery(), this.lastOptions);
}
}
@@ -5,9 +5,10 @@ import {
EventEmitter,
signal,
computed,
effect,
ChangeDetectionStrategy,
inject
inject,
OnChanges,
SimpleChanges
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@@ -197,7 +198,7 @@ type SortOption = 'relevance' | 'name' | 'modified';
}
`]
})
export class SearchResultsComponent {
export class SearchResultsComponent implements OnChanges {
@Input() set results(value: SearchResult[]) {
this._results.set(value);
this.buildGroups(value);
@@ -206,6 +207,7 @@ export class SearchResultsComponent {
@Input() collapseAll: boolean = false;
@Input() showMoreContext: boolean = false;
@Input() contextLines: number = 2;
@Input() highlight: boolean = true;
@Output() noteOpen = new EventEmitter<{ noteId: string; line?: number }>();
@@ -216,13 +218,12 @@ export class SearchResultsComponent {
private groups = signal<ResultGroup[]>([]);
sortBy: SortOption = 'relevance';
constructor() {
// Watch for collapseAll changes
effect(() => {
if (this.collapseAll !== undefined) {
this.applyCollapseAll(this.collapseAll);
}
});
constructor() {}
ngOnChanges(changes: SimpleChanges): void {
if (changes['collapseAll'] && !changes['collapseAll'].firstChange) {
this.applyCollapseAll(this.collapseAll);
}
}
/**
@@ -347,6 +348,19 @@ export class SearchResultsComponent {
return '';
}
// If highlight is disabled, strip any pre-highlighted HTML and return plain text
if (!this.highlight) {
return this.highlighter.stripHtml(match.context);
}
// If context already contains highlight markup from Meilisearch
// (e.g., <mark> or <em> tags), return it as-is to preserve server-side highlights.
// We purposely avoid escaping HTML here because it is produced by our backend with safe tags.
const hasPreHighlightedHtml = /<(mark|em)>/i.test(match.context);
if (hasPreHighlightedHtml) {
return match.context;
}
// If we have ranges, use them for precise highlighting
if (match.ranges && match.ranges.length > 0) {
return this.highlighter.highlightWithRanges(match.context, match.ranges);
@@ -0,0 +1,251 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TagsViewComponent } from './tags-view.component';
import { TagInfo } from '../../types';
import { signal } from '@angular/core';
describe('TagsViewComponent', () => {
let component: TagsViewComponent;
let fixture: ComponentFixture<TagsViewComponent>;
const mockTags: TagInfo[] = [
{ name: 'AI', count: 6 },
{ name: 'angular', count: 1 },
{ name: 'debian/server', count: 3 },
{ name: 'debian/desktop', count: 2 },
{ name: 'automatisation', count: 2 },
{ name: 'budget', count: 1 },
{ name: 'docker/automation', count: 5 },
{ name: 'docker/test', count: 3 },
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TagsViewComponent]
}).compileComponents();
fixture = TestBed.createComponent(TagsViewComponent);
component = fixture.componentInstance;
// Set required input
fixture.componentRef.setInput('tags', mockTags);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('Filtering', () => {
it('should filter tags by search query', () => {
component.searchQuery.set('docker');
fixture.detectChanges();
const displayed = component['filteredTags']();
expect(displayed.length).toBe(2);
expect(displayed.every(t => t.name.includes('docker'))).toBe(true);
});
it('should be case insensitive', () => {
component.searchQuery.set('DOCKER');
fixture.detectChanges();
const displayed = component['filteredTags']();
expect(displayed.length).toBe(2);
});
it('should handle accents', () => {
component.searchQuery.set('automatisation');
fixture.detectChanges();
const displayed = component['filteredTags']();
expect(displayed.length).toBe(1);
});
});
describe('Sorting', () => {
it('should sort alphabetically A-Z', () => {
component.sortMode.set('alpha-asc');
fixture.detectChanges();
const sorted = component['sortedTags']();
expect(sorted[0].name).toBe('AI');
expect(sorted[sorted.length - 1].name).toBe('docker/test');
});
it('should sort alphabetically Z-A', () => {
component.sortMode.set('alpha-desc');
fixture.detectChanges();
const sorted = component['sortedTags']();
expect(sorted[0].name).toBe('docker/test');
expect(sorted[sorted.length - 1].name).toBe('AI');
});
it('should sort by frequency descending', () => {
component.sortMode.set('freq-desc');
fixture.detectChanges();
const sorted = component['sortedTags']();
expect(sorted[0].name).toBe('AI'); // count: 6
expect(sorted[1].name).toBe('docker/automation'); // count: 5
});
it('should sort by frequency ascending', () => {
component.sortMode.set('freq-asc');
fixture.detectChanges();
const sorted = component['sortedTags']();
const firstCount = sorted[0].count;
const lastCount = sorted[sorted.length - 1].count;
expect(firstCount).toBeLessThanOrEqual(lastCount);
});
});
describe('Grouping', () => {
it('should not group when mode is "none"', () => {
component.groupMode.set('none');
fixture.detectChanges();
const groups = component.displayedGroups();
expect(groups.length).toBe(1);
expect(groups[0].label).toBe('all');
expect(groups[0].tags.length).toBe(mockTags.length);
});
it('should group by hierarchy', () => {
component.groupMode.set('hierarchy');
fixture.detectChanges();
const groups = component.displayedGroups();
// Should have groups: AI, angular, debian, automatisation, budget, docker
expect(groups.length).toBeGreaterThan(1);
const dockerGroup = groups.find(g => g.label === 'docker');
expect(dockerGroup).toBeDefined();
expect(dockerGroup!.tags.length).toBe(2);
});
it('should group alphabetically', () => {
component.groupMode.set('alpha');
fixture.detectChanges();
const groups = component.displayedGroups();
// Should have groups for A, B, D
expect(groups.length).toBeGreaterThan(1);
const aGroup = groups.find(g => g.label === 'A');
expect(aGroup).toBeDefined();
expect(aGroup!.tags.length).toBeGreaterThan(0);
});
});
describe('Group expansion', () => {
it('should toggle group expansion', () => {
component.groupMode.set('hierarchy');
fixture.detectChanges();
const groups = component.displayedGroups();
const firstGroup = groups[0];
const initialState = firstGroup.isExpanded;
component.toggleGroup(firstGroup.label);
fixture.detectChanges();
const updatedGroups = component.displayedGroups();
const updatedGroup = updatedGroups.find(g => g.label === firstGroup.label);
expect(updatedGroup!.isExpanded).toBe(!initialState);
});
it('should auto-expand all groups when switching modes', () => {
component.groupMode.set('hierarchy');
fixture.detectChanges();
const groups = component.displayedGroups();
expect(groups.every(g => g.isExpanded)).toBe(true);
});
});
describe('Display helpers', () => {
it('should display short name in hierarchy mode', () => {
component.groupMode.set('hierarchy');
const result = component.displayTagName('docker', 'docker/automation');
expect(result).toBe('automation');
});
it('should display full name in non-hierarchy mode', () => {
component.groupMode.set('none');
const result = component.displayTagName('docker', 'docker/automation');
expect(result).toBe('docker/automation');
});
it('should handle root tag correctly', () => {
component.groupMode.set('hierarchy');
const result = component.displayTagName('docker', 'docker');
expect(result).toBe('docker');
});
});
describe('Events', () => {
it('should emit tagSelected on tag click', (done) => {
component.tagSelected.subscribe((tagName: string) => {
expect(tagName).toBe('docker/automation');
done();
});
component.onTagClick('docker/automation');
});
});
describe('Reset', () => {
it('should reset all filters', () => {
component.searchQuery.set('test');
component.sortMode.set('freq-desc');
component.groupMode.set('hierarchy');
component.resetFilters();
expect(component.searchQuery()).toBe('');
expect(component.sortMode()).toBe('alpha-asc');
expect(component.groupMode()).toBe('none');
});
});
describe('Stats', () => {
it('should calculate total tags correctly', () => {
const total = component.totalTags();
expect(total).toBe(mockTags.length);
});
it('should calculate displayed tags after filtering', () => {
component.searchQuery.set('docker');
fixture.detectChanges();
const displayed = component.totalDisplayedTags();
expect(displayed).toBe(2);
});
});
describe('Performance', () => {
it('should handle large tag lists efficiently', () => {
const largeTags: TagInfo[] = Array.from({ length: 1000 }, (_, i) => ({
name: `tag-${i}`,
count: Math.floor(Math.random() * 100)
}));
fixture.componentRef.setInput('tags', largeTags);
fixture.detectChanges();
const start = performance.now();
component.sortMode.set('freq-desc');
fixture.detectChanges();
const duration = performance.now() - start;
expect(duration).toBeLessThan(50); // Should be < 50ms
});
});
});
+443 -183
View File
@@ -8,149 +8,252 @@ import {
computed,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TagInfo } from '../../types';
interface TagSection {
letter: string;
type SortMode = 'alpha-asc' | 'alpha-desc' | 'freq-desc' | 'freq-asc';
type GroupMode = 'none' | 'hierarchy' | 'alpha';
interface TagGroup {
label: string;
tags: TagInfo[];
isExpanded: boolean;
level: number;
}
@Component({
selector: 'app-tags-view',
imports: [CommonModule],
imports: [CommonModule, FormsModule],
styles: [
`
:host {
--tv-scroll-thumb-light: color-mix(in srgb, var(--text-main) 35%, transparent);
--tv-scroll-thumb-dark: color-mix(in srgb, var(--text-muted) 55%, transparent);
--tv-scroll-thumb: rgba(128, 128, 128, 0.3);
--tv-scroll-track: transparent;
}
:host .custom-scrollbar {
:host-context(.dark) {
--tv-scroll-thumb: rgba(200, 200, 200, 0.2);
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: var(--tv-scroll-thumb-light) var(--tv-scroll-track);
scrollbar-color: var(--tv-scroll-thumb) var(--tv-scroll-track);
}
:host([data-theme='dark']) .custom-scrollbar,
:host-context(.dark) .custom-scrollbar {
scrollbar-color: var(--tv-scroll-thumb-dark) var(--tv-scroll-track);
}
:host .custom-scrollbar::-webkit-scrollbar {
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
:host .custom-scrollbar::-webkit-scrollbar-track {
.custom-scrollbar::-webkit-scrollbar-track {
background: var(--tv-scroll-track);
}
:host .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--tv-scroll-thumb-light);
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--tv-scroll-thumb);
border-radius: 3px;
}
.tag-item {
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.tag-item:hover {
transform: translateX(2px);
}
.group-header {
transition: background-color 0.15s ease;
}
.rotate-90 {
transform: rotate(90deg);
}
.expand-icon {
transition: transform 0.2s ease;
}
/* Toolbar layout and compact controls */
.toolbar-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
.control-select {
min-width: 0;
flex: 1 1 0;
height: 32px;
padding: 0 10px;
border-radius: 9999px;
}
:host([data-theme='dark']) .custom-scrollbar::-webkit-scrollbar-thumb,
:host-context(.dark) .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--tv-scroll-thumb-dark);
.icon-btn {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
}
/* Remove blue focus rectangle and native search decorations */
#tag-search,
#tag-search:focus,
#tag-search:focus-visible {
outline: none !important;
box-shadow: none !important;
}
#tag-search {
appearance: none;
-webkit-appearance: none;
border: 0;
}
input[type='search']::-webkit-search-decoration,
input[type='search']::-webkit-search-cancel-button,
input[type='search']::-webkit-search-results-button,
input[type='search']::-webkit-search-results-decoration {
display: none;
.icon-btn--muted {
color: var(--obs-l-text-muted);
}
`,
],
template: `
<div class="flex h-full flex-col gap-4 p-3 bg-card">
<div class="w-full rounded-full border border-border bg-card px-3 py-1.5 shadow-subtle focus-within:outline-none focus-within:ring-0">
<label class="sr-only" for="tag-search">Rechercher des tags</label>
<div class="flex items-center gap-2.5 text-text-muted">
<svg class="h-4 w-4" 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-5.2-5.2m0 0A7 7 0 105.8 5.8a7 7 0 0010 10z" />
</svg>
<div class="flex h-full flex-col bg-obs-l-bg-main dark:bg-obs-d-bg-main">
<!-- Toolbar -->
<div class="flex flex-col gap-3 border-b border-obs-l-border dark:border-obs-d-border p-3">
<!-- Search bar -->
<div class="relative">
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-obs-l-text-muted dark:text-obs-d-text-muted">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
id="tag-search"
type="search"
[value]="searchTerm()"
(input)="onSearchChange($event.target?.value ?? '')"
[(ngModel)]="searchQuery"
(ngModelChange)="onSearchChange()"
placeholder="Rechercher un tag..."
class="w-full border-0 bg-transparent text-sm text-text-main placeholder:text-text-muted outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0"
class="w-full rounded-md border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary pl-9 pr-3 py-2 text-sm text-obs-l-text-main dark:text-obs-d-text-main placeholder:text-obs-l-text-muted dark:placeholder:text-obs-d-text-muted focus:outline-none focus:ring-2 focus:ring-obs-l-accent dark:focus:ring-obs-d-accent"
/>
</div>
<!-- Controls row -->
<div class="toolbar-row">
<!-- Sort dropdown -->
<select
[(ngModel)]="sortMode"
(ngModelChange)="onSortChange()"
class="control-select border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary text-xs text-obs-l-text-main dark:text-obs-d-text-main focus:outline-none focus:ring-2 focus:ring-obs-l-accent dark:focus:ring-obs-d-accent"
>
<option value="alpha-asc">A → Z</option>
<option value="alpha-desc">Z → A</option>
<option value="freq-desc">Fréquence ↓</option>
<option value="freq-asc">Fréquence ↑</option>
</select>
<!-- Group mode dropdown -->
<select
[(ngModel)]="groupMode"
(ngModelChange)="onGroupModeChange()"
class="control-select border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary text-xs text-obs-l-text-main dark:text-obs-d-text-main focus:outline-none focus:ring-2 focus:ring-obs-l-accent dark:focus:ring-obs-d-accent"
>
<option value="none">Sans groupe</option>
<option value="hierarchy">Hiérarchie</option>
<option value="alpha">Alphabétique</option>
</select>
<!-- Reset button -->
<button
type="button"
(click)="resetFilters()"
class="icon-btn border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary text-obs-l-text-muted dark:text-obs-d-text-muted hover:text-obs-l-text-main dark:hover:text-obs-d-text-main hover:bg-obs-l-bg-hover dark:hover:bg-obs-d-bg-hover transition-colors"
title="Réinitialiser les filtres"
aria-label="Réinitialiser les filtres"
>
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<!-- Expand all toggle (only when grouped) -->
<button
type="button"
(click)="toggleExpandAll()"
[disabled]="groupMode() === 'none'"
[attr.aria-pressed]="allExpanded()"
class="icon-btn border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary transition-colors"
[ngClass]="groupMode() === 'none' ? 'text-obs-l-text-muted/60 dark:text-obs-d-text-muted/60 cursor-not-allowed' : (allExpanded() ? 'text-obs-l-text-main dark:text-obs-d-text-main' : 'text-obs-l-text-muted dark:text-obs-d-text-muted hover:text-obs-l-text-main dark:hover:text-obs-d-text-main hover:bg-obs-l-bg-hover dark:hover:bg-obs-d-bg-hover')"
[title]="allExpanded() ? 'Replier tout' : 'Déplier tout'"
[attr.aria-label]="allExpanded() ? 'Replier tout' : 'Déplier tout'"
>
<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="M4 12h16M4 6h16M4 18h16" />
</svg>
</button>
</div>
</div>
<div class="flex flex-1 gap-3 overflow-hidden">
<div class="custom-scrollbar flex-1 overflow-y-auto pr-1">
@if (displayedSections().length > 0) {
<div class="space-y-4">
@for (section of displayedSections(); track section.letter) {
<section class="space-y-2">
<header class="flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-wide text-text-muted">
{{ section.letter }}
</h2>
<span class="text-[0.65rem] text-text-muted/70">
{{ section.tags.length }} tag(s)
<!-- Tags list -->
<div class="flex-1 overflow-y-auto custom-scrollbar">
@if (displayedGroups().length === 0) {
<div class="flex flex-col items-center justify-center h-full text-obs-l-text-muted dark:text-obs-d-text-muted p-8">
<svg class="h-12 w-12 mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<p class="text-sm">Aucun tag trouvé</p>
</div>
} @else {
<div class="py-2">
@for (group of displayedGroups(); track group.label) {
<div class="mb-1">
<!-- Group header (if grouped) -->
@if (groupMode() !== 'none') {
<button
type="button"
(click)="toggleGroup(group.label)"
class="group-header w-full flex items-center justify-between px-3 py-2 text-left hover:bg-obs-l-bg-hover dark:hover:bg-obs-d-bg-hover"
>
<div class="flex items-center gap-2">
<svg
class="h-3 w-3 text-obs-l-text-muted dark:text-obs-d-text-muted expand-icon"
[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>
<span class="text-xs font-semibold text-obs-l-text-muted dark:text-obs-d-text-muted uppercase tracking-wide">
{{ group.label }}
</span>
</div>
<span class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
{{ group.tags.length }}
</span>
</header>
<div class="flex flex-wrap gap-2">
@for (tag of section.tags; track tag.name) {
</button>
}
<!-- Tags in group -->
@if (groupMode() === 'none' || group.isExpanded) {
<div [class.pl-6]="groupMode() !== 'none'">
@for (tag of group.tags; track tag.name) {
<button
type="button"
class="chip justify-between px-2 py-0.5 text-[0.7rem]"
(click)="tagSelected.emit(tag.name)"
(click)="onTagClick(tag.name)"
class="tag-item w-full flex items-center justify-between px-3 py-2 text-left hover:bg-obs-l-bg-hover dark:hover:bg-obs-d-bg-hover group"
>
<span class="flex items-center gap-2 text-text-main">
<span aria-hidden="true">🔖</span>
<span class="truncate">{{ tag.name }}</span>
<div class="flex items-center gap-2 flex-1 min-w-0">
<svg class="h-3.5 w-3.5 text-obs-l-text-muted dark:text-obs-d-text-muted flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<span class="text-sm text-obs-l-text-main dark:text-obs-d-text-main truncate">
{{ displayTagName(group.label, tag.name) }}
</span>
</div>
<span class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted ml-2 flex-shrink-0">
{{ tag.count }}
</span>
<span class="badge text-text-muted">{{ tag.count }}</span>
</button>
}
</div>
</section>
}
</div>
} @else {
<div class="flex h-full items-center justify-center rounded-lg border border-dashed border-border bg-bg-muted px-4 py-6 text-center text-sm text-text-muted">
Aucun tag ne correspond à votre recherche.
</div>
}
</div>
<nav class="custom-scrollbar sticky top-0 flex h-full w-12 flex-col items-center justify-start gap-2 self-start rounded-lg border border-border bg-card px-2 py-3 text-[0.7rem] font-medium text-text-muted shadow-subtle overflow-y-auto">
<span class="text-[0.6rem] uppercase tracking-wide text-text-muted/70">A–Z</span>
<div class="grid grid-cols-1 gap-1">
@for (letter of availableLetters(); track letter) {
<button
type="button"
class="flex h-9 w-9 items-center justify-center rounded-full border border-transparent text-[0.7rem] transition"
[ngClass]="activeLetter() === letter ? 'border-border bg-bg-muted text-text-main font-semibold' : 'text-text-muted hover:border-border hover:bg-bg-muted'"
[attr.aria-pressed]="activeLetter() === letter"
(click)="onLetterClick(letter)"
>
{{ letter }}
</button>
}
</div>
}
</div>
</nav>
}
</div>
<!-- Stats footer -->
<div class="border-t border-obs-l-border dark:border-obs-d-border px-3 py-2">
<p class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
{{ totalDisplayedTags() }} tag(s) affiché(s) sur {{ totalTags() }}
</p>
</div>
</div>
`,
@@ -162,64 +265,199 @@ export class TagsViewComponent {
numeric: true,
});
// Inputs/Outputs
readonly tags = input.required<TagInfo[]>();
readonly tagSelected = output<string>();
readonly searchTerm = signal('');
readonly activeLetter = signal<string | null>(null);
// State signals
searchQuery = signal('');
sortMode = signal<SortMode>('alpha-asc');
groupMode = signal<GroupMode>('none');
private expandedGroups = signal<Set<string>>(new Set());
private userCustomExpansion = signal(false);
private lastMode: GroupMode | null = null;
private readonly sections = computed<TagSection[]>(() => {
const rawTerm = this.searchTerm().trim();
const normalizedTerm = this.normalize(rawTerm);
const allTags = (this.tags() ?? []).slice().sort((a, b) => this.collator.compare(a.name, b.name));
// Normalized tags with forward slashes
private readonly normalizedTags = computed<TagInfo[]>(() => {
return (this.tags() ?? []).map(t => ({
...t,
name: (t.name ?? '').replace(/\\/g, '/')
}));
});
const filtered = rawTerm
? allTags.filter((tag) => this.normalize(tag.name).includes(normalizedTerm))
: allTags;
// Filtered tags based on search query
private readonly filteredTags = computed<TagInfo[]>(() => {
const query = this.normalize(this.searchQuery().trim());
const allTags = this.normalizedTags();
if (!query) return allTags;
return allTags.filter(tag =>
this.normalize(tag.name).includes(query)
);
});
// Sorted tags based on sort mode
private readonly sortedTags = computed<TagInfo[]>(() => {
const tags = [...this.filteredTags()];
const mode = this.sortMode();
switch (mode) {
case 'alpha-asc':
return tags.sort((a, b) => this.collator.compare(a.name, b.name));
case 'alpha-desc':
return tags.sort((a, b) => this.collator.compare(b.name, a.name));
case 'freq-desc':
return tags.sort((a, b) => b.count - a.count || this.collator.compare(a.name, b.name));
case 'freq-asc':
return tags.sort((a, b) => a.count - b.count || this.collator.compare(a.name, b.name));
default:
return tags;
}
});
// Grouped tags based on group mode
readonly displayedGroups = computed<TagGroup[]>(() => {
const tags = this.sortedTags();
const mode = this.groupMode();
const expanded = this.expandedGroups();
if (mode === 'none') {
return [{
label: 'all',
tags,
isExpanded: true,
level: 0
}];
}
if (mode === 'hierarchy') {
return this.buildHierarchyGroups(tags, expanded);
}
if (mode === 'alpha') {
return this.buildAlphaGroups(tags, expanded);
}
return [];
});
// Stats
readonly totalTags = computed(() => this.normalizedTags().length);
readonly totalDisplayedTags = computed(() => this.filteredTags().length);
constructor() {
// Auto-expand groups on mode change; don't override user toggles
effect(() => {
const mode = this.groupMode();
const labels = this.getCurrentGroupLabels();
// If switched mode, reset custom flag and initialize expansion
if (this.lastMode !== mode) {
this.userCustomExpansion.set(false);
this.lastMode = mode;
}
if (mode === 'none') {
if (this.expandedGroups().size > 0) this.expandedGroups.set(new Set());
return;
}
// Initialize only when user hasn't interacted yet or when current set mismatches and user hasn't customized
const current = this.expandedGroups();
const needsInit = current.size === 0 || current.size !== labels.length || labels.some(l => !current.has(l));
if (!this.userCustomExpansion() && needsInit) {
this.expandedGroups.set(new Set(labels));
}
}, { allowSignalWrites: true });
}
// Compute current group labels from mode + sorted tags
private getCurrentGroupLabels(): string[] {
const mode = this.groupMode();
const tags = this.sortedTags();
if (mode === 'none') return [];
if (mode === 'hierarchy') {
const roots = new Set<string>();
for (const t of tags) {
const root = (t.name?.split('/')?.[0] || t.name) ?? '';
if (root) roots.add(root);
}
return Array.from(roots).sort((a, b) => this.collator.compare(a, b));
}
// alpha
const letters = new Set<string>();
for (const t of tags) letters.add(this.extractLetter(t.name));
return Array.from(letters).sort((a, b) => this.sortLetters(a, b));
}
// Build hierarchy groups (e.g., docker/automation, docker/test -> docker group)
private buildHierarchyGroups(tags: TagInfo[], expanded: Set<string>): TagGroup[] {
const grouped = new Map<string, TagInfo[]>();
for (const tag of filtered) {
for (const tag of tags) {
const parts = tag.name.split('/');
const root = parts[0] || tag.name;
if (!grouped.has(root)) {
grouped.set(root, []);
}
grouped.get(root)!.push(tag);
}
return Array.from(grouped.entries())
.sort((a, b) => this.collator.compare(a[0], b[0]))
.map(([label, tags]) => ({
label,
tags: tags.sort((a, b) => this.collator.compare(a.name, b.name)),
isExpanded: expanded.has(label),
level: 0
}));
}
// Build alphabetical groups (A, B, C, etc.)
private buildAlphaGroups(tags: TagInfo[], expanded: Set<string>): TagGroup[] {
const grouped = new Map<string, TagInfo[]>();
for (const tag of tags) {
const letter = this.extractLetter(tag.name);
if (!grouped.has(letter)) {
grouped.set(letter, []);
}
grouped.get(letter)!.push(tag);
}
const sortedSections = Array.from(grouped.entries())
return Array.from(grouped.entries())
.sort((a, b) => this.sortLetters(a[0], b[0]))
.map(([letter, tags]) => ({
letter,
tags: tags.slice().sort((a, b) => this.collator.compare(a.name, b.name)),
.map(([label, tags]) => ({
label,
tags,
isExpanded: expanded.has(label),
level: 0
}));
}
return sortedSections;
});
private extractLetter(name: string): string {
const normalized = this.normalize(name).toUpperCase();
const char = normalized.charAt(0);
if (!char) return '#';
if (/[A-Z]/.test(char)) return char;
if (/[0-9]/.test(char)) return '#';
return char;
}
readonly availableLetters = computed(() => this.sections().map((section) => section.letter));
readonly displayedSections = computed(() => {
const active = this.activeLetter();
if (!active) {
return this.sections();
}
return this.sections().filter((section) => section.letter === active);
});
constructor() {
effect(() => {
const letters = this.availableLetters();
const active = this.activeLetter();
if (active && !letters.includes(active)) {
this.resetLetterState();
}
});
effect(() => {
if (!this.searchTerm().trim()) {
this.resetLetterState();
}
});
private sortLetters(a: string, b: string): number {
const rank = (value: string): number => {
if (value === '#') return 0;
if (/^[A-Z]$/.test(value)) return value.charCodeAt(0) - 64;
return 100 + value.charCodeAt(0);
};
const diff = rank(a) - rank(b);
return diff !== 0 ? diff : this.collator.compare(a, b);
}
private normalize(value: string): string {
@@ -229,58 +467,80 @@ export class TagsViewComponent {
.toLowerCase();
}
private extractLetter(name: string): string {
const normalized = this.normalize(name).toUpperCase();
const char = normalized.charAt(0);
if (!char) {
return '#';
}
if (/[A-Z]/.test(char)) {
return char;
}
if (/[0-9]/.test(char)) {
return '#';
}
return char;
}
private sortLetters(a: string, b: string): number {
const rank = (value: string): number => {
if (value === '#') {
return 0;
/**
* Display helper: remove the group prefix from tag name
* Example: group="docker", name="docker/automation" => "automation"
*/
displayTagName(groupLabel: string, tagName: string): string {
const mode = this.groupMode();
// In hierarchy mode, remove the root prefix
if (mode === 'hierarchy') {
const full = String(tagName ?? '');
const prefix = String(groupLabel ?? '');
if (!prefix || full === prefix) return full;
if (full.startsWith(prefix + '/')) {
return full.slice(prefix.length + 1);
}
if (/^[A-Z]$/.test(value)) {
return value.charCodeAt(0) - 64;
}
return tagName;
}
// Event handlers
onSearchChange(): void {
// Search query is bound via ngModel, no action needed
}
onSortChange(): void {
// Sort mode is bound via ngModel, computed signals will update
}
onGroupModeChange(): void {
// Group mode is bound via ngModel, computed signals will update
}
toggleGroup(label: string): void {
this.expandedGroups.update(groups => {
const newGroups = new Set(groups);
if (newGroups.has(label)) {
newGroups.delete(label);
} else {
newGroups.add(label);
}
return 100 + value.charCodeAt(0);
};
const diff = rank(a) - rank(b);
return diff !== 0 ? diff : this.collator.compare(a, b);
return newGroups;
});
this.userCustomExpansion.set(true);
}
onSearchChange(raw: string): void {
this.searchTerm.set(raw);
if (raw.trim()) {
this.activeLetter.set(null);
onTagClick(tagName: string): void {
this.tagSelected.emit(tagName);
}
resetFilters(): void {
this.searchQuery.set('');
this.sortMode.set('alpha-asc');
this.groupMode.set('none');
this.userCustomExpansion.set(false);
}
// Expand all toggle for grouped modes
allExpanded = computed(() => {
if (this.groupMode() === 'none') return false;
const labels = this.getCurrentGroupLabels();
const current = this.expandedGroups();
return labels.length > 0 && labels.every(l => current.has(l));
});
toggleExpandAll(): void {
if (this.groupMode() === 'none') return;
const labels = this.getCurrentGroupLabels();
if (this.allExpanded()) {
this.expandedGroups.set(new Set());
} else {
this.expandedGroups.set(new Set(labels));
}
}
clearSearch(): void {
this.searchTerm.set('');
this.resetLetterState();
}
onLetterClick(letter: string): void {
const active = this.activeLetter();
if (active === letter) {
this.resetLetterState();
return;
}
this.activeLetter.set(letter);
}
private resetLetterState(): void {
this.activeLetter.set(null);
this.userCustomExpansion.set(true);
}
}