feat: global search and keyboard shortcuts

Ctrl/⌘ K (or /) opens a search over the projects and tickets of all my
projects, driven by the keyboard. g plus a letter moves between project
pages, c creates a ticket on the board, e edits the open ticket and ?
lists every shortcut. My work can now be searched by text through the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:32:07 +02:00
parent 4148335457
commit d6a434ca1d
24 changed files with 584 additions and 21 deletions

View File

@@ -1,2 +1,3 @@
export * from './lib/tracker.routes';
export * from './lib/ui/notifications-bell';
export * from './lib/ui/command-palette';

View File

@@ -9,6 +9,7 @@ import {
Component,
computed,
effect,
HostListener,
inject,
input,
signal,
@@ -43,6 +44,7 @@ import {
boardFilterOf,
IssuePriority,
IssueSummary,
isTyping,
overWip,
POINT_SCALE,
ReleaseStatus,
@@ -843,6 +845,23 @@ export class BoardPage {
);
}
/** `c`: new ticket (not while typing, nor with a drawer open). */
@HostListener('document:keydown', ['$event'])
protected onKey(event: KeyboardEvent): void {
if (
event.key !== 'c' ||
event.ctrlKey ||
event.metaKey ||
event.altKey ||
isTyping(event.target) ||
!this.project.canContribute() ||
document.querySelector('[role="dialog"]')
)
return;
event.preventDefault();
this.openCreate();
}
protected openCreate(): void {
const types = this.typeOptions();
if (!types.some((t) => t.value === this.newType()))

View File

@@ -43,6 +43,7 @@ import {
IssueLink,
IssueLinkType,
IssuePriority,
isTyping,
matches,
mentionQuery,
mentionSegments,
@@ -1121,6 +1122,23 @@ export class IssuePage {
}
/** A screenshot pasted anywhere on the ticket is attached to it. */
/** `e`: edit the ticket (not while typing, nor with a drawer open). */
@HostListener('document:keydown', ['$event'])
protected onKey(event: KeyboardEvent): void {
if (
event.key !== 'e' ||
event.ctrlKey ||
event.metaKey ||
event.altKey ||
isTyping(event.target) ||
!this.project.canContribute() ||
document.querySelector('[role="dialog"]')
)
return;
event.preventDefault();
this.openEdit();
}
@HostListener('document:paste', ['$event'])
protected pasted(event: ClipboardEvent): void {
const target = event.target as HTMLElement | null;

View File

@@ -0,0 +1,385 @@
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
effect,
ElementRef,
HostListener,
inject,
signal,
viewChild,
} from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { ProjectsFacade } from '@task-stream/web/tracker/application';
import {
GO_TO,
isTyping,
IssuesGateway,
IssueSummary,
projectKeyIn,
SHORTCUTS,
} from '@task-stream/web/tracker/domain';
import {
catchError,
debounceTime,
distinctUntilChanged,
map,
of,
switchMap,
} from 'rxjs';
interface Result {
id: string;
kind: 'project' | 'issue';
label: string;
detail: string;
url: string;
}
/**
* Global search (Ctrl/⌘ K or /) over my projects and their tickets, and the
* keyboard shortcuts of the app (? lists them).
*/
@Component({
selector: 'ts-command-palette',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button
type="button"
class="ts-nav-link ts-palette__open"
title="Buscar (Ctrl K)"
(click)="open('search')"
>
Buscar <kbd>⌘K</kbd>
</button>
@if (mode(); as current) {
<button
type="button"
class="ts-palette__backdrop"
tabindex="-1"
aria-label="Cerrar"
(click)="close()"
></button>
<div
class="ts-palette"
role="dialog"
aria-modal="true"
[attr.aria-label]="current === 'help' ? 'Atajos de teclado' : 'Buscar'"
>
@if (current === 'search') {
<input
#search
class="ts-palette__input"
type="search"
placeholder="Busca un ticket (WEB-12, título…) o un proyecto"
role="combobox"
aria-controls="ts-palette-results"
[attr.aria-expanded]="results().length > 0"
[attr.aria-activedescendant]="
results().length ? 'ts-palette-' + active() : null
"
[value]="text()"
(input)="text.set($any($event.target).value); active.set(0)"
(keydown)="onKey($event)"
/>
<ul
id="ts-palette-results"
class="ts-palette__results"
role="listbox"
>
@for (result of results(); track result.id; let i = $index) {
<li
[id]="'ts-palette-' + i"
role="option"
[attr.aria-selected]="i === active()"
[class.is-active]="i === active()"
tabindex="-1"
(mouseenter)="active.set(i)"
(click)="go(result)"
(keydown.enter)="go(result)"
>
<span class="ts-palette__kind">{{
result.kind === 'project' ? 'Proyecto' : 'Ticket'
}}</span>
<span class="ts-palette__label">{{ result.label }}</span>
<span class="ts-palette__detail">{{ result.detail }}</span>
</li>
} @empty {
<li class="ts-palette__empty" role="presentation">
{{
text().trim().length < 2
? 'Escribe al menos dos letras'
: 'Nada encontrado'
}}
</li>
}
</ul>
<p class="ts-palette__hint">
↑ ↓ para elegir · Enter para abrir · ? atajos · Esc cerrar
</p>
} @else {
<h2 class="ts-palette__title">Atajos de teclado</h2>
<table class="ts-palette__keys">
<tbody>
@for (shortcut of shortcuts; track shortcut.keys) {
<tr>
<td>
<kbd>{{ shortcut.keys }}</kbd>
</td>
<td>{{ shortcut.what }}</td>
<td class="ts-palette__detail">{{ shortcut.where }}</td>
</tr>
}
</tbody>
</table>
}
</div>
}
`,
styles: `
.ts-palette__open {
display: inline-flex;
gap: 0.375rem;
align-items: center;
border: 0;
background: none;
font: inherit;
cursor: pointer;
kbd {
font-size: 0.6875rem;
opacity: 0.7;
}
}
.ts-palette__backdrop {
border: 0;
cursor: default;
position: fixed;
inset: 0;
z-index: 1000;
background: rgb(0 0 0 / 35%);
}
.ts-palette {
position: fixed;
top: 12vh;
left: 50%;
z-index: 1001;
width: min(40rem, calc(100vw - 2rem));
max-height: 70vh;
overflow: auto;
transform: translateX(-50%);
padding: 0.75rem;
border-radius: 0.75rem;
background: var(--ts-surface);
border: 1px solid var(--ts-line);
box-shadow: 0 20px 60px rgb(0 0 0 / 30%);
color: var(--ts-ink);
}
.ts-palette__input {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid var(--ts-line-strong);
border-radius: 0.5rem;
background: var(--ts-bg);
color: inherit;
font: inherit;
font-size: 1rem;
}
.ts-palette__results {
margin: 0.5rem 0 0;
padding: 0;
list-style: none;
li {
display: grid;
grid-template-columns: 4.5rem minmax(0, 1fr) auto;
gap: 0.625rem;
align-items: center;
padding: 0.5rem 0.625rem;
border-radius: 0.375rem;
cursor: pointer;
}
li.is-active {
background: color-mix(in srgb, var(--ts-brand) 14%, transparent);
}
}
.ts-palette__kind,
.ts-palette__detail,
.ts-palette__hint {
color: var(--ts-muted);
font-size: 0.75rem;
}
.ts-palette__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ts-palette__empty {
display: block !important;
color: var(--ts-muted);
cursor: default !important;
}
.ts-palette__hint {
margin: 0.5rem 0 0;
}
.ts-palette__title {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.ts-palette__keys {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
td {
padding: 0.375rem 0.5rem;
border-top: 1px solid var(--ts-line);
}
}
`,
})
export class CommandPalette {
private readonly router = inject(Router);
private readonly projects = inject(ProjectsFacade);
private readonly issues = inject(IssuesGateway);
private readonly searchInput =
viewChild<ElementRef<HTMLInputElement>>('search');
protected readonly mode = signal<'search' | 'help' | null>(null);
protected readonly text = signal('');
protected readonly active = signal(0);
protected readonly shortcuts = SHORTCUTS;
/** Waiting for the second key of `g …`. */
private pendingGo = false;
private goTimer: ReturnType<typeof setTimeout> | null = null;
private readonly foundIssues = signal<IssueSummary[]>([]);
protected readonly results = computed<Result[]>(() => {
const query = this.text().trim().toLowerCase();
if (query.length < 2) return [];
const projects = this.projects
.sorted()
.filter(
(p) =>
p.name.toLowerCase().includes(query) ||
p.key.toLowerCase().includes(query),
)
.slice(0, 4)
.map((p) => ({
id: `p:${p.id}`,
kind: 'project' as const,
label: p.name,
detail: p.key,
url: `/p/${p.key}/board`,
}));
const issues = this.foundIssues().map((i) => ({
id: `i:${i.id}`,
kind: 'issue' as const,
label: `${i.key} · ${i.title}`,
detail: i.assignee?.name ?? '',
url: `/p/${i.key.slice(0, i.key.lastIndexOf('-'))}/issues/${i.key}`,
}));
return [...projects, ...issues];
});
constructor() {
toObservable(this.text)
.pipe(
map((text) => text.trim()),
debounceTime(250),
distinctUntilChanged(),
switchMap((search) =>
search.length < 2
? of([])
: this.issues.mine({ assignedToMe: false, search, limit: 8 }).pipe(
map((page) => page.items),
catchError(() => of([])),
),
),
takeUntilDestroyed(inject(DestroyRef)),
)
.subscribe((found) => this.foundIssues.set(found));
effect(() => {
if (this.mode() === 'search') this.searchInput()?.nativeElement.focus();
});
}
@HostListener('document:keydown', ['$event'])
protected onDocumentKey(event: KeyboardEvent): void {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
this.open('search');
return;
}
if (event.key === 'Escape' && this.mode()) {
this.close();
return;
}
if (
this.mode() ||
isTyping(event.target) ||
event.ctrlKey ||
event.metaKey ||
event.altKey
)
return;
if (this.pendingGo) {
this.pendingGo = false;
const target = GO_TO[event.key.toLowerCase()];
if (target) {
event.preventDefault();
const key = projectKeyIn(this.router.url);
if (!target.inProject) void this.router.navigateByUrl(target.path);
else if (key)
void this.router.navigateByUrl(`/p/${key}/${target.path}`);
}
return;
}
if (event.key === '/') {
event.preventDefault();
this.open('search');
} else if (event.key === '?') {
event.preventDefault();
this.open('help');
} else if (event.key === 'g') {
this.pendingGo = true;
if (this.goTimer) clearTimeout(this.goTimer);
this.goTimer = setTimeout(() => (this.pendingGo = false), 1200);
}
}
protected open(mode: 'search' | 'help'): void {
if (mode === 'search' && !this.projects.sorted().length)
this.projects.load();
this.mode.set(mode);
}
protected close(): void {
this.mode.set(null);
this.text.set('');
this.active.set(0);
}
protected onKey(event: KeyboardEvent): void {
const results = this.results();
if (event.key === 'ArrowDown') {
event.preventDefault();
this.active.set(Math.min(this.active() + 1, results.length - 1));
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this.active.set(Math.max(this.active() - 1, 0));
} else if (event.key === 'Enter') {
event.preventDefault();
const result = results[this.active()];
if (result) this.go(result);
} else if (event.key === '?' && !this.text()) {
event.preventDefault();
this.mode.set('help');
}
}
protected go(result: Result): void {
this.close();
void this.router.navigateByUrl(result.url);
}
}