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

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

View File

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

View File

@@ -11,6 +11,8 @@ export interface MyIssuesFilter {
statuses?: string[];
/** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */
categories?: StatusCategory[];
/** Matches key, title or description. */
search?: string;
limit: 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.categories?.length)
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([
this.issues
.find(query)

View File

@@ -104,11 +104,18 @@ export class IssueResolver {
categories: StatusCategory[] | undefined,
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: 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, {
assignedToMe,
statuses: statuses ?? undefined,
categories: categories ?? undefined,
search: search ?? undefined,
limit,
offset,
});

View File

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

View File

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