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

@@ -2041,6 +2041,15 @@
"$ref": "#/components/schemas/StatusCategory" "$ref": "#/components/schemas/StatusCategory"
} }
} }
},
{
"name": "search",
"required": false,
"in": "query",
"description": "Matches key, title or description",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {

View File

@@ -900,7 +900,16 @@ type Query {
""" """
Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA]) Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])
""" """
myIssues(assignedToMe: Boolean! = true, categories: [StatusCategory!], limit: Int! = 50, offset: Int! = 0, statuses: [String!]): IssuePage! myIssues(
assignedToMe: Boolean! = true
categories: [StatusCategory!]
limit: Int! = 50
offset: Int! = 0
"""Matches key, title or description (global search)"""
search: String
statuses: [String!]
): IssuePage!
"""My notifications, newest first, and how many are unread""" """My notifications, newest first, and how many are unread"""
myNotifications(limit: Int! = 30, unreadOnly: Boolean! = false): MyNotifications! myNotifications(limit: Int! = 30, unreadOnly: Boolean! = false): MyNotifications!

View File

@@ -3724,6 +3724,8 @@ export interface operations {
status?: string[]; status?: string[];
/** @description Whatever each project calls its statuses */ /** @description Whatever each project calls its statuses */
category?: components["schemas"]["StatusCategory"][]; category?: components["schemas"]["StatusCategory"][];
/** @description Matches key, title or description */
search?: string;
}; };
header?: never; header?: never;
path?: never; path?: never;

View File

@@ -11,7 +11,10 @@ import { filter, map } from 'rxjs';
import { AuthFacade } from '@task-stream/web/identity/application'; import { AuthFacade } from '@task-stream/web/identity/application';
import { UserMenuComponent } from '@task-stream/web/identity/presenters'; import { UserMenuComponent } from '@task-stream/web/identity/presenters';
import { AppShellComponent } from '@task-stream/web/shared/ui'; import { AppShellComponent } from '@task-stream/web/shared/ui';
import { NotificationsBell } from '@task-stream/web/tracker/presenters'; import {
CommandPalette,
NotificationsBell,
} from '@task-stream/web/tracker/presenters';
@Component({ @Component({
selector: 'ts-root', selector: 'ts-root',
@@ -23,6 +26,7 @@ import { NotificationsBell } from '@task-stream/web/tracker/presenters';
AppShellComponent, AppShellComponent,
UserMenuComponent, UserMenuComponent,
NotificationsBell, NotificationsBell,
CommandPalette,
], ],
template: ` template: `
<ts-app-shell> <ts-app-shell>
@@ -48,6 +52,7 @@ import { NotificationsBell } from '@task-stream/web/tracker/presenters';
@if (auth.isAuthenticated()) { @if (auth.isAuthenticated()) {
<!-- One projectable node per @if branch, or neither reaches the slot. --> <!-- One projectable node per @if branch, or neither reaches the slot. -->
<ng-container shellUser> <ng-container shellUser>
<ts-command-palette />
<ts-notifications-bell /> <ts-notifications-bell />
<ts-user-menu /> <ts-user-menu />
</ng-container> </ng-container>

View File

@@ -21,6 +21,7 @@
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | | 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | | 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta | | ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos; `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista |
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias | | 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños | | 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo | | 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |

View File

@@ -279,12 +279,12 @@ que funcionan también sin token.
### Queries ### Queries
| Query | Devuelve | | Query | Devuelve |
| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `me` | El usuario autenticado, con sus roles en Pireon | | `me` | El usuario autenticado, con sus roles en Pireon |
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) | | `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
| `user(id)` | Un usuario | | `user(id)` | Un usuario |
| `myProjects` | Proyectos de los que soy miembro | | `myProjects` | Proyectos de los que soy miembro |
| `myIssues(assignedToMe, statuses, categories, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) | | `myIssues(assignedToMe, statuses, categories, search, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo | | `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
| `publicProjects` | Proyectos públicos (sin token) | | `publicProjects` | Proyectos públicos (sin token) |
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` | | `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` |

View File

@@ -43,6 +43,7 @@ export interface CrossProjectIssueFilter {
assigneeId?: string; assigneeId?: string;
statuses?: string[]; statuses?: string[];
categories?: StatusCategory[]; categories?: StatusCategory[];
search?: string;
limit: number; limit: number;
offset: number; offset: number;
} }

View File

@@ -21,6 +21,7 @@ export class SearchMyIssuesQueryHandler
assigneeId: query.filter.assignedToMe ? query.viewerId : undefined, assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
statuses: query.filter.statuses, statuses: query.filter.statuses,
categories: query.filter.categories, categories: query.filter.categories,
search: query.filter.search?.trim().slice(0, 200) || undefined,
limit: Math.min(query.filter.limit, 200), limit: Math.min(query.filter.limit, 200),
offset: query.filter.offset, offset: query.filter.offset,
}); });

View File

@@ -11,6 +11,8 @@ export interface MyIssuesFilter {
statuses?: string[]; statuses?: string[];
/** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */ /** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */
categories?: StatusCategory[]; categories?: StatusCategory[];
/** Matches key, title or description. */
search?: string;
limit: number; limit: number;
offset: number; offset: number;
} }

View File

@@ -161,6 +161,14 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
if (filter.statuses?.length) query['status'] = { $in: filter.statuses }; if (filter.statuses?.length) query['status'] = { $in: filter.statuses };
if (filter.categories?.length) if (filter.categories?.length)
query['statusCategory'] = { $in: filter.categories }; query['statusCategory'] = { $in: filter.categories };
if (filter.search?.trim()) {
const pattern = new RegExp(escapeRegex(filter.search.trim()), 'i');
query['$or'] = [
{ key: pattern },
{ title: pattern },
{ description: pattern },
];
}
const [docs, total] = await Promise.all([ const [docs, total] = await Promise.all([
this.issues this.issues
.find(query) .find(query)

View File

@@ -104,11 +104,18 @@ export class IssueResolver {
categories: StatusCategory[] | undefined, categories: StatusCategory[] | undefined,
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: number, @Args('limit', { type: () => Int, defaultValue: 50 }) limit: number,
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number, @Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
@Args('search', {
type: () => String,
nullable: true,
description: 'Matches key, title or description (global search)',
})
search?: string | null,
) { ) {
return this.issuesService.mine(me.id, { return this.issuesService.mine(me.id, {
assignedToMe, assignedToMe,
statuses: statuses ?? undefined, statuses: statuses ?? undefined,
categories: categories ?? undefined, categories: categories ?? undefined,
search: search ?? undefined,
limit, limit,
offset, offset,
}); });

View File

@@ -408,6 +408,12 @@ export class MyIssuesQueryDto extends PageQueryDto {
@Transform(toList) @Transform(toList)
@IsEnum(StatusCategory, { each: true }) @IsEnum(StatusCategory, { each: true })
category?: StatusCategory[]; category?: StatusCategory[];
@ApiPropertyOptional({ description: 'Matches key, title or description' })
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
} }
export class UpcomingReleasesQueryDto { export class UpcomingReleasesQueryDto {

View File

@@ -130,6 +130,7 @@ export class IssuesController {
assignedToMe: query.assignedToMe, assignedToMe: query.assignedToMe,
statuses: query.status, statuses: query.status,
categories: query.category, categories: query.category,
search: query.search,
limit: query.limit, limit: query.limit,
offset: query.offset, offset: query.offset,
}); });

View File

@@ -9,3 +9,4 @@ export * from './lib/models/views';
export * from './lib/models/estimation'; export * from './lib/models/estimation';
export * from './lib/models/swimlanes'; export * from './lib/models/swimlanes';
export * from './lib/models/roadmap'; export * from './lib/models/roadmap';
export * from './lib/models/shortcuts';

View File

@@ -73,6 +73,8 @@ export interface MyIssuesFilter {
/** true: assigned to me. false: any ticket of my projects. */ /** true: assigned to me. false: any ticket of my projects. */
assignedToMe: boolean; assignedToMe: boolean;
categories?: StatusCategory[]; categories?: StatusCategory[];
/** Matches key, title or description. */
search?: string;
limit: number; limit: number;
} }

View File

@@ -0,0 +1,22 @@
import { GO_TO, isTyping, projectKeyIn } from './shortcuts';
describe('shortcuts', () => {
it('knows the project of a URL', () => {
expect(projectKeyIn('/p/WEB/issues/WEB-3')).toBe('WEB');
expect(projectKeyIn('/explorar')).toBeNull();
expect(GO_TO['k']).toEqual({ path: 'backlog', inProject: true });
});
it('leaves keys alone while typing', () => {
expect(isTyping({ tagName: 'TEXTAREA' } as unknown as EventTarget)).toBe(
true,
);
expect(
isTyping({
tagName: 'DIV',
isContentEditable: false,
} as unknown as EventTarget),
).toBe(false);
expect(isTyping(null)).toBe(false);
});
});

View File

@@ -0,0 +1,43 @@
/** Keys that do something, as the help lists them. */
export const SHORTCUTS: { keys: string; what: string; where: string }[] = [
{
keys: 'Ctrl K · ⌘ K · /',
what: 'Buscar tickets y proyectos',
where: 'En todas partes',
},
{ keys: '?', what: 'Ver estos atajos', where: 'En todas partes' },
{ keys: 'g p', what: 'Ir a mis proyectos', where: 'En todas partes' },
{ keys: 'g e', what: 'Ir a explorar', where: 'En todas partes' },
{ keys: 'g b', what: 'Ir al tablero', where: 'Dentro de un proyecto' },
{ keys: 'g k', what: 'Ir al backlog', where: 'Dentro de un proyecto' },
{ keys: 'g s', what: 'Ir a los sprints', where: 'Dentro de un proyecto' },
{ keys: 'g r', what: 'Ir a la hoja de ruta', where: 'Dentro de un proyecto' },
{ keys: 'c', what: 'Crear un ticket', where: 'Tablero' },
{ keys: 'e', what: 'Editar el ticket', where: 'Ficha de un ticket' },
{ keys: 'Esc', what: 'Cerrar', where: 'Búsqueda y ayuda' },
];
/** Where `g` + key goes; project pages are relative to `/p/KEY`. */
export const GO_TO: Record<string, { path: string; inProject: boolean }> = {
p: { path: '/', inProject: false },
e: { path: '/explorar', inProject: false },
b: { path: 'board', inProject: true },
k: { path: 'backlog', inProject: true },
s: { path: 'sprints', inProject: true },
r: { path: 'roadmap', inProject: true },
};
/** Typing in a field is typing, not a shortcut. */
export function isTyping(target: EventTarget | null): boolean {
const element = target as HTMLElement | null;
if (!element?.tagName) return false;
return (
['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName) ||
element.isContentEditable
);
}
/** The project key in a URL like /p/WEB/board, if any. */
export function projectKeyIn(url: string): string | null {
return /^\/p\/([^/?#]+)/.exec(url)?.[1] ?? null;
}

View File

@@ -87,6 +87,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
assignedToMe: filter.assignedToMe, assignedToMe: filter.assignedToMe,
categories: filter.categories as never, categories: filter.categories as never,
limit: filter.limit, limit: filter.limit,
search: filter.search?.trim() || null,
}) })
.pipe( .pipe(
map((d) => ({ map((d) => ({

View File

@@ -879,6 +879,7 @@ export type MyIssuesQueryVariables = Exact<{
assignedToMe: boolean; assignedToMe: boolean;
categories?: Array<StatusCategory> | StatusCategory | null | undefined; categories?: Array<StatusCategory> | StatusCategory | null | undefined;
limit: number; limit: number;
search?: string | null | undefined;
}>; }>;
export type MyIssuesQuery = { export type MyIssuesQuery = {
@@ -5520,6 +5521,14 @@ export const MyIssuesDocument = {
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } },
}, },
}, },
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'search' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
], ],
selectionSet: { selectionSet: {
kind: 'SelectionSet', kind: 'SelectionSet',
@@ -5552,6 +5561,14 @@ export const MyIssuesDocument = {
name: { kind: 'Name', value: 'limit' }, name: { kind: 'Name', value: 'limit' },
}, },
}, },
{
kind: 'Argument',
name: { kind: 'Name', value: 'search' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'search' },
},
},
], ],
selectionSet: { selectionSet: {
kind: 'SelectionSet', kind: 'SelectionSet',

View File

@@ -134,11 +134,13 @@ query MyIssues(
$assignedToMe: Boolean! $assignedToMe: Boolean!
$categories: [StatusCategory!] $categories: [StatusCategory!]
$limit: Int! $limit: Int!
$search: String
) { ) {
myIssues( myIssues(
assignedToMe: $assignedToMe assignedToMe: $assignedToMe
categories: $categories categories: $categories
limit: $limit limit: $limit
search: $search
) { ) {
total total
items { items {

View File

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

View File

@@ -9,6 +9,7 @@ import {
Component, Component,
computed, computed,
effect, effect,
HostListener,
inject, inject,
input, input,
signal, signal,
@@ -43,6 +44,7 @@ import {
boardFilterOf, boardFilterOf,
IssuePriority, IssuePriority,
IssueSummary, IssueSummary,
isTyping,
overWip, overWip,
POINT_SCALE, POINT_SCALE,
ReleaseStatus, 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 { protected openCreate(): void {
const types = this.typeOptions(); const types = this.typeOptions();
if (!types.some((t) => t.value === this.newType())) if (!types.some((t) => t.value === this.newType()))

View File

@@ -43,6 +43,7 @@ import {
IssueLink, IssueLink,
IssueLinkType, IssueLinkType,
IssuePriority, IssuePriority,
isTyping,
matches, matches,
mentionQuery, mentionQuery,
mentionSegments, mentionSegments,
@@ -1121,6 +1122,23 @@ export class IssuePage {
} }
/** A screenshot pasted anywhere on the ticket is attached to it. */ /** 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']) @HostListener('document:paste', ['$event'])
protected pasted(event: ClipboardEvent): void { protected pasted(event: ClipboardEvent): void {
const target = event.target as HTMLElement | null; 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);
}
}