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

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