Skip to content

Commit 7ca0f00

Browse files
feat: add command palette for quick request navigation
Cmd/Ctrl+P opens a fuzzy search overlay to jump to any request by name, method, URL, or group. Features: - Fuzzy matching with scoring (consecutive and word-start bonuses) - Arrow key navigation with mouse hover selection - Enter to scroll editor to selected request - Escape to dismiss - Color-coded HTTP method badges Includes pure logic layer with 12 unit tests for item building, fuzzy matching, and search functionality. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ec72e5a commit 7ca0f00

9 files changed

Lines changed: 383 additions & 2 deletions

frontend/src/app/app.component.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,12 @@
221221
(onClose)="showOutlinePanel = false"
222222
(onRequestSelect)="scrollEditorToRequest($event)">
223223
</app-outline-panel>
224+
<app-command-palette
225+
[isOpen]="showCommandPalette"
226+
[requests]="currentFile.requests"
227+
(onClose)="showCommandPalette = false"
228+
(onRequestSelect)="scrollEditorToRequest($event)">
229+
</app-command-palette>
224230
<app-history-detail-modal
225231
[isOpen]="showHistoryModal"
226232
[item]="selectedHistoryItem"

frontend/src/app/app.component.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
ScriptSnippetModalComponent
1818
} from './components';
1919
import { OutlinePanelComponent } from './components/outline-panel/outline-panel.component';
20+
import { CommandPaletteComponent } from './components/command-palette/command-palette.component';
2021
import { ToastContainerComponent } from './components/toast-container/toast-container.component';
2122
import { FileTab, ResponseData, HistoryItem, Request, ScriptLogEntry, ActiveRunProgress, ChainEntryPreview } from './models/http.models';
2223
import { HttpService } from './services/http.service';
@@ -107,7 +108,8 @@ type AlertType = 'info' | 'success' | 'warning' | 'danger';
107108
ToastContainerComponent,
108109
UpdateNotificationComponent,
109110
ScriptSnippetModalComponent,
110-
OutlinePanelComponent
111+
OutlinePanelComponent,
112+
CommandPaletteComponent
111113
],
112114
templateUrl: './app.component.html',
113115
styleUrls: ['./app.component.scss']
@@ -244,6 +246,7 @@ export class AppComponent implements OnInit, OnDestroy {
244246
showHistoryModal = false;
245247
selectedHistoryItem: HistoryItem | null = null;
246248
showOutlinePanel = false;
249+
showCommandPalette = false;
247250
showLoadTestResults = false;
248251
loadTestMetrics: any = null;
249252
showDonationModal = false;
@@ -719,6 +722,9 @@ export class AppComponent implements OnInit, OnDestroy {
719722
case 'closeOutline':
720723
this.showOutlinePanel = false;
721724
return;
725+
case 'toggleCommandPalette':
726+
this.showCommandPalette = !this.showCommandPalette;
727+
return;
722728
case 'cancelRequest':
723729
void this.cancelActiveRequest();
724730
return;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
@if (isOpen()) {
2+
<div class="rr-palette-overlay" (click)="onClose.emit()">
3+
<div class="rr-palette-dialog" (click)="$event.stopPropagation()" (keydown)="onKeydown($event)">
4+
<input
5+
#searchInput
6+
class="rr-palette-input"
7+
type="text"
8+
placeholder="Type to search requests..."
9+
[ngModel]="query()"
10+
(ngModelChange)="onQueryChange($event)"
11+
autocomplete="off"
12+
spellcheck="false"
13+
/>
14+
<div class="rr-palette-results">
15+
@for (match of results(); track match.item.requestIndex; let i = $index) {
16+
<div
17+
class="rr-palette-item"
18+
[class.rr-palette-item--selected]="i === selectedIndex()"
19+
(click)="selectItem(match)"
20+
(mouseenter)="selectedIndex.set(i)"
21+
>
22+
<span [class]="getMethodClass(match.item.method)">{{ match.item.method }}</span>
23+
<span class="rr-palette-label">{{ match.item.label || match.item.url }}</span>
24+
@if (match.item.group) {
25+
<span class="rr-palette-group">{{ match.item.group }}</span>
26+
}
27+
</div>
28+
} @empty {
29+
<div class="rr-palette-empty">No matching requests</div>
30+
}
31+
</div>
32+
</div>
33+
</div>
34+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
.rr-palette-overlay {
2+
position: fixed;
3+
inset: 0;
4+
z-index: 1000;
5+
display: flex;
6+
justify-content: center;
7+
padding-top: 15vh;
8+
background: rgba(0, 0, 0, 0.4);
9+
}
10+
11+
.rr-palette-dialog {
12+
width: 560px;
13+
max-height: 420px;
14+
display: flex;
15+
flex-direction: column;
16+
background: var(--rr-surface, #1e1e1e);
17+
border: 1px solid var(--rr-border, #333);
18+
border-radius: 8px;
19+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
20+
overflow: hidden;
21+
align-self: flex-start;
22+
}
23+
24+
.rr-palette-input {
25+
width: 100%;
26+
padding: 12px 16px;
27+
border: none;
28+
border-bottom: 1px solid var(--rr-border, #333);
29+
background: transparent;
30+
color: var(--rr-text, #ccc);
31+
font-size: 14px;
32+
font-family: inherit;
33+
outline: none;
34+
box-sizing: border-box;
35+
36+
&::placeholder {
37+
color: var(--rr-text-muted, #666);
38+
}
39+
}
40+
41+
.rr-palette-results {
42+
overflow-y: auto;
43+
flex: 1;
44+
min-height: 0;
45+
}
46+
47+
.rr-palette-item {
48+
display: flex;
49+
align-items: center;
50+
gap: 8px;
51+
padding: 8px 16px;
52+
cursor: pointer;
53+
color: var(--rr-text, #ccc);
54+
font-size: 13px;
55+
56+
&--selected {
57+
background: var(--rr-highlight, #2a2a2a);
58+
}
59+
}
60+
61+
.rr-palette-method {
62+
font-size: 11px;
63+
font-weight: 600;
64+
padding: 2px 6px;
65+
border-radius: 3px;
66+
min-width: 52px;
67+
text-align: center;
68+
flex-shrink: 0;
69+
70+
&--get { color: #61affe; background: rgba(97, 175, 254, 0.1); }
71+
&--post { color: #49cc90; background: rgba(73, 204, 144, 0.1); }
72+
&--put { color: #fca130; background: rgba(252, 161, 48, 0.1); }
73+
&--patch { color: #50e3c2; background: rgba(80, 227, 194, 0.1); }
74+
&--delete { color: #f93e3e; background: rgba(249, 62, 62, 0.1); }
75+
&--head { color: #9012fe; background: rgba(144, 18, 254, 0.1); }
76+
&--options { color: #0d5aa7; background: rgba(13, 90, 167, 0.1); }
77+
}
78+
79+
.rr-palette-label {
80+
flex: 1;
81+
overflow: hidden;
82+
text-overflow: ellipsis;
83+
white-space: nowrap;
84+
}
85+
86+
.rr-palette-group {
87+
color: var(--rr-text-muted, #666);
88+
font-size: 12px;
89+
flex-shrink: 0;
90+
}
91+
92+
.rr-palette-empty {
93+
padding: 24px 16px;
94+
text-align: center;
95+
color: var(--rr-text-muted, #666);
96+
font-size: 13px;
97+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { Component, input, output, signal, computed, ElementRef, viewChild, effect } from '@angular/core';
2+
import { CommonModule } from '@angular/common';
3+
import { FormsModule } from '@angular/forms';
4+
import { buildPaletteItems, searchPaletteItems, FuzzyMatch } from './command-palette.logic';
5+
6+
@Component({
7+
selector: 'app-command-palette',
8+
standalone: true,
9+
imports: [CommonModule, FormsModule],
10+
templateUrl: './command-palette.component.html',
11+
styleUrls: ['./command-palette.component.scss']
12+
})
13+
export class CommandPaletteComponent {
14+
isOpen = input.required<boolean>();
15+
requests = input.required<{ method: string; url: string; name?: string; group?: string }[]>();
16+
17+
onClose = output<void>();
18+
onRequestSelect = output<number>();
19+
20+
query = signal('');
21+
selectedIndex = signal(0);
22+
searchInput = viewChild<ElementRef>('searchInput');
23+
24+
private items = computed(() => buildPaletteItems(this.requests()));
25+
results = computed(() => searchPaletteItems(this.items(), this.query()));
26+
27+
constructor() {
28+
effect(() => {
29+
if (this.isOpen()) {
30+
this.query.set('');
31+
this.selectedIndex.set(0);
32+
setTimeout(() => this.searchInput()?.nativeElement.focus(), 0);
33+
}
34+
});
35+
}
36+
37+
onQueryChange(value: string) {
38+
this.query.set(value);
39+
this.selectedIndex.set(0);
40+
}
41+
42+
onKeydown(event: KeyboardEvent) {
43+
const results = this.results();
44+
switch (event.key) {
45+
case 'ArrowDown':
46+
event.preventDefault();
47+
this.selectedIndex.set(Math.min(this.selectedIndex() + 1, results.length - 1));
48+
break;
49+
case 'ArrowUp':
50+
event.preventDefault();
51+
this.selectedIndex.set(Math.max(this.selectedIndex() - 1, 0));
52+
break;
53+
case 'Enter':
54+
event.preventDefault();
55+
if (results.length > 0) {
56+
this.selectItem(results[this.selectedIndex()]);
57+
}
58+
break;
59+
case 'Escape':
60+
event.preventDefault();
61+
this.onClose.emit();
62+
break;
63+
}
64+
}
65+
66+
selectItem(match: FuzzyMatch) {
67+
this.onRequestSelect.emit(match.item.requestIndex);
68+
this.onClose.emit();
69+
}
70+
71+
getMethodClass(method: string): string {
72+
return `rr-palette-method rr-palette-method--${method.toLowerCase()}`;
73+
}
74+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { buildPaletteItems, fuzzyMatch, searchPaletteItems } from './command-palette.logic';
2+
3+
describe('command-palette.logic', () => {
4+
describe('buildPaletteItems', () => {
5+
it('builds items from requests', () => {
6+
const items = buildPaletteItems([
7+
{ method: 'GET', url: 'https://api.com/users', name: 'GetUsers', group: 'Users' },
8+
{ method: 'POST', url: 'https://api.com/login' }
9+
]);
10+
expect(items).toHaveLength(2);
11+
expect(items[0]).toEqual({
12+
requestIndex: 0, method: 'GET', label: 'GetUsers', group: 'Users', url: 'https://api.com/users'
13+
});
14+
expect(items[1].label).toBe('');
15+
expect(items[1].group).toBeNull();
16+
});
17+
18+
it('defaults method to GET', () => {
19+
const items = buildPaletteItems([{ method: '', url: '/test' }]);
20+
expect(items[0].method).toBe('GET');
21+
});
22+
});
23+
24+
describe('fuzzyMatch', () => {
25+
it('returns all chars highlighted for exact match', () => {
26+
const result = fuzzyMatch('hello', 'hello');
27+
expect(result).not.toBeNull();
28+
expect(result!.highlights).toEqual([0, 1, 2, 3, 4]);
29+
});
30+
31+
it('returns null for no match', () => {
32+
expect(fuzzyMatch('abc', 'xyz')).toBeNull();
33+
});
34+
35+
it('matches non-contiguous characters', () => {
36+
const result = fuzzyMatch('GetUsers', 'gtu');
37+
expect(result).not.toBeNull();
38+
expect(result!.highlights.length).toBe(3);
39+
});
40+
41+
it('returns highlights for empty query', () => {
42+
const result = fuzzyMatch('anything', '');
43+
expect(result).not.toBeNull();
44+
expect(result!.highlights).toEqual([]);
45+
});
46+
47+
it('gives bonus for word-start matches', () => {
48+
const wordStart = fuzzyMatch('get-users', 'gu');
49+
const midWord = fuzzyMatch('xgxuxsers', 'gu');
50+
expect(wordStart!.score).toBeGreaterThan(midWord!.score);
51+
});
52+
});
53+
54+
describe('searchPaletteItems', () => {
55+
const items = buildPaletteItems([
56+
{ method: 'GET', url: 'https://api.com/users', name: 'GetUsers', group: 'Users' },
57+
{ method: 'POST', url: 'https://api.com/login', name: 'Login', group: 'Auth' },
58+
{ method: 'DELETE', url: 'https://api.com/users/1', name: 'DeleteUser' }
59+
]);
60+
61+
it('returns all items for empty query', () => {
62+
expect(searchPaletteItems(items, '')).toHaveLength(3);
63+
});
64+
65+
it('filters by method', () => {
66+
const results = searchPaletteItems(items, 'DELETE');
67+
expect(results[0].item.label).toBe('DeleteUser');
68+
});
69+
70+
it('filters by name', () => {
71+
const results = searchPaletteItems(items, 'login');
72+
expect(results.length).toBeGreaterThan(0);
73+
expect(results[0].item.label).toBe('Login');
74+
});
75+
76+
it('filters by group', () => {
77+
const results = searchPaletteItems(items, 'Auth');
78+
expect(results[0].item.group).toBe('Auth');
79+
});
80+
81+
it('sorts by relevance score', () => {
82+
const results = searchPaletteItems(items, 'user');
83+
expect(results.length).toBeGreaterThanOrEqual(2);
84+
// Results should be sorted by score descending
85+
for (let i = 1; i < results.length; i++) {
86+
expect(results[i - 1].score).toBeGreaterThanOrEqual(results[i].score);
87+
}
88+
});
89+
});
90+
});

0 commit comments

Comments
 (0)