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:
@@ -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()))
|
||||
|
||||
@@ -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;
|
||||
|
||||
385
libs/web/tracker/presenters/src/lib/ui/command-palette.ts
Normal file
385
libs/web/tracker/presenters/src/lib/ui/command-palette.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user