ompchamber 3.0.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -8
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +333 -333
- package/dist/client/static/js/index-5yfre1kw.css +1 -0
- package/dist/client/static/js/{index-prbmx3yj.js → index-qhzsybkn.js} +643 -617
- package/package.json +1 -1
- package/src/cli/lib/args.js +4 -3
- package/src/cli/lib/commands/restart.js +7 -6
- package/src/cli/lib/commands/stop.js +38 -31
- package/src/cli/lib/commands/update.js +3 -2
- package/src/cli/lib/stop-scope.js +23 -0
- package/src/cli/lib/stop-scope.test.js +41 -0
- package/src/client/components/common/code-editor/handler.test.ts +274 -0
- package/src/client/components/common/code-editor/handler.ts +175 -0
- package/src/client/components/common/code-editor/index.tsx +185 -220
- package/src/client/components/common/code-surface/index.tsx +109 -12
- package/src/client/components/common/lazy-panels.tsx +5 -1
- package/src/client/components/common/viewport.ts +5 -3
- package/src/client/components/layout/RightActivityBar.tsx +2 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +21 -4
- package/src/client/components/layout/desktop-layout/index.tsx +24 -4
- package/src/client/components/mobile/RightSidebar.tsx +11 -4
- package/src/client/components/workspace/browser-panel/AddressBar.tsx +1 -10
- package/src/client/components/workspace/browser-panel/Screencast.tsx +4 -5
- package/src/client/components/workspace/browser-panel/index.tsx +6 -6
- package/src/client/components/workspace/browser-toolbar/index.tsx +22 -12
- package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Todo.tsx +1 -1
- package/src/client/components/workspace/editor/CommandPalette.test.ts +242 -0
- package/src/client/components/workspace/editor/CommandPalette.tsx +138 -0
- package/src/client/components/workspace/editor/FindWidget.test.ts +261 -0
- package/src/client/components/workspace/editor/FindWidget.tsx +219 -0
- package/src/client/components/workspace/editor/Toolbar.tsx +19 -1
- package/src/client/components/workspace/editor/index.tsx +153 -53
- package/src/client/components/workspace/file-explorer/Modals.tsx +79 -2
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +43 -113
- package/src/client/components/workspace/todo-panel/PhaseGroup.tsx +76 -0
- package/src/client/components/workspace/todo-panel/Row.tsx +69 -0
- package/src/client/components/workspace/todo-panel/index.tsx +161 -0
- package/src/client/hooks/editor/find-harness.test-util.ts +170 -0
- package/src/client/hooks/editor/scroll-container.test.ts +102 -0
- package/src/client/hooks/editor/scroll-container.ts +26 -0
- package/src/client/hooks/editor/use-code-window.ts +2 -20
- package/src/client/hooks/editor/use-editor-find-write.test.ts +170 -0
- package/src/client/hooks/editor/use-editor-find.test.ts +118 -0
- package/src/client/hooks/editor/use-editor-find.ts +321 -0
- package/src/client/hooks/editor/use-editor-history.test.ts +216 -0
- package/src/client/hooks/editor/use-editor-history.ts +134 -0
- package/src/client/hooks/editor/use-occurrences.test.ts +236 -0
- package/src/client/hooks/editor/use-occurrences.ts +98 -0
- package/src/client/hooks/workspace/file-tree-actions.test.ts +348 -0
- package/src/client/hooks/workspace/file-tree-actions.ts +250 -0
- package/src/client/hooks/workspace/session-todos.ts +102 -0
- package/src/client/tailwind.css +30 -0
- package/src/server/lib/bundler/css.test.ts +87 -0
- package/src/server/lib/bundler/css.ts +17 -2
- package/src/server/lib/bundler/nested-at-rules.test.ts +100 -0
- package/src/server/lib/bundler/nested-at-rules.ts +146 -0
- package/src/server/lib/lifecycle/launch-mode.test.ts +20 -1
- package/src/server/lib/lifecycle/launch-mode.ts +26 -0
- package/src/server/lib/lifecycle/restart.ts +9 -14
- package/src/server/lib/omp/session/todos.ts +100 -0
- package/src/server/routes/fs/action-create.test.ts +90 -0
- package/src/server/routes/fs/action.ts +23 -0
- package/src/server/routes/omp/index.ts +2 -0
- package/src/server/routes/omp/session-todos.ts +80 -0
- package/src/shared/lib/chat/{todo-parser.ts → todo/parser.ts} +17 -0
- package/src/shared/lib/chat/todo/progress.ts +97 -0
- package/src/shared/lib/chat/todo/snapshot.test.ts +156 -0
- package/src/shared/lib/chat/todo/snapshot.ts +215 -0
- package/src/shared/lib/code/editor/commands.test.ts +155 -0
- package/src/shared/lib/code/editor/commands.ts +260 -0
- package/src/shared/lib/code/editor/edits.test.ts +100 -0
- package/src/shared/lib/code/editor/edits.ts +122 -0
- package/src/shared/lib/code/editor/find-status.test.ts +50 -0
- package/src/shared/lib/code/editor/find-status.ts +36 -0
- package/src/shared/lib/code/editor/find.test.ts +203 -0
- package/src/shared/lib/code/editor/find.ts +224 -0
- package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.test.ts +1 -1
- package/src/shared/lib/code/editor/keymap.test.ts +109 -0
- package/src/shared/lib/code/editor/keymap.ts +151 -0
- package/src/shared/lib/code/editor/layers.test.ts +77 -0
- package/src/shared/lib/code/editor/layers.ts +67 -0
- package/src/shared/lib/code/editor/line-edits.test.ts +131 -0
- package/src/shared/lib/code/editor/line-edits.ts +159 -0
- package/src/shared/lib/code/editor/lines.test.ts +81 -0
- package/src/shared/lib/code/editor/lines.ts +83 -0
- package/src/shared/lib/code/editor/marks.test.ts +86 -0
- package/src/shared/lib/code/editor/marks.ts +106 -0
- package/src/shared/lib/code/editor/occurrences.test.ts +117 -0
- package/src/shared/lib/code/editor/occurrences.ts +111 -0
- package/src/shared/lib/code/editor/palette.test.ts +91 -0
- package/src/shared/lib/code/editor/palette.ts +92 -0
- package/src/shared/lib/code/editor/reset-css.test.ts +35 -0
- package/src/shared/lib/code/editor/reset-css.ts +32 -0
- package/src/shared/lib/code/editor/text-commands.test.ts +178 -0
- package/src/shared/lib/code/editor/text-commands.ts +154 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +75 -0
- package/src/shared/lib/code/syntax-highlight.ts +29 -17
- package/src/shared/lib/code/windowed-highlight.ts +52 -1
- package/src/shared/lib/ui/key-binding.test.ts +115 -0
- package/src/shared/lib/ui/key-binding.ts +143 -0
- package/src/shared/lib/util/platform.test.ts +87 -0
- package/src/shared/lib/util/platform.ts +30 -0
- package/src/shared/lib/workspace/keymap.test.ts +82 -0
- package/src/shared/lib/workspace/keymap.ts +30 -0
- package/src/shared/lib/workspace/refresh-cadence.ts +9 -0
- package/src/shared/lib/workspace/right-panels.ts +8 -0
- package/src/shared/lib/workspace/session-state/store.ts +0 -1
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/todo.ts +108 -0
- package/dist/client/static/js/index-ranztes9.css +0 -1
- package/src/server/lib/lifecycle/restart.test.ts +0 -21
- /package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.ts +0 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The command palette's rendering.
|
|
8
|
+
*
|
|
9
|
+
* The list itself is built and filtered in `shared/lib/code/editor/palette`
|
|
10
|
+
* (tested there); what this covers is the interaction — that the field takes
|
|
11
|
+
* focus, that a filter narrows the list, that the arrow keys move the highlight
|
|
12
|
+
* without leaving the list, and that Enter runs the command that is actually
|
|
13
|
+
* highlighted rather than the first one.
|
|
14
|
+
*
|
|
15
|
+
* Rendered directly with `h()` (no JSX) against happy-dom; the component module
|
|
16
|
+
* is imported dynamically, after those globals exist.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
20
|
+
import { Window } from 'happy-dom';
|
|
21
|
+
import type { ComponentChildren, h as PreactH, render as PreactRender } from 'preact';
|
|
22
|
+
import type { act as PreactAct } from 'preact/test-utils';
|
|
23
|
+
import { EDITOR_KEY_BINDINGS, type EditorCommand } from '@/shared/lib/code/editor/keymap';
|
|
24
|
+
import { buildPaletteEntries } from '@/shared/lib/code/editor/palette';
|
|
25
|
+
import { isMacPlatform } from '@/shared/lib/util/platform';
|
|
26
|
+
|
|
27
|
+
let CommandPalette: (props: { onRun: (command: EditorCommand) => void; onClose: () => void }) => ComponentChildren;
|
|
28
|
+
let h: typeof PreactH;
|
|
29
|
+
let render: typeof PreactRender;
|
|
30
|
+
let act: typeof PreactAct;
|
|
31
|
+
|
|
32
|
+
let container: HTMLElement;
|
|
33
|
+
const ran: EditorCommand[] = [];
|
|
34
|
+
let closes = 0;
|
|
35
|
+
|
|
36
|
+
async function mount() {
|
|
37
|
+
await act(async () => {
|
|
38
|
+
render(h(CommandPalette, { onRun: (command) => ran.push(command), onClose: () => closes++ }), container);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function field(): HTMLInputElement | null {
|
|
43
|
+
return container.querySelector('input[aria-label="Command palette"]');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The visible rows, in order, as their label text. */
|
|
47
|
+
function rows(): string[] {
|
|
48
|
+
return Array.from(container.querySelectorAll('button')).map((button) => button.textContent ?? '');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The row the palette currently highlights (it renders a return-key glyph). */
|
|
52
|
+
function activeRow(): string {
|
|
53
|
+
return rows().find((text) => text.length > 0 && container.querySelector('button .lucide-corner-down-left') !== null)
|
|
54
|
+
? (Array.from(container.querySelectorAll('button')).find((button) => button.querySelector('.lucide-corner-down-left'))?.textContent ?? '')
|
|
55
|
+
: '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function typeQuery(value: string) {
|
|
59
|
+
const input = field();
|
|
60
|
+
if (!input) return;
|
|
61
|
+
input.value = value;
|
|
62
|
+
await act(async () => {
|
|
63
|
+
input.dispatchEvent(new globalThis.Event('input', { bubbles: true }));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function pressKey(key: string) {
|
|
68
|
+
await act(async () => {
|
|
69
|
+
field()?.dispatchEvent(new globalThis.KeyboardEvent('keydown', { key, bubbles: true }));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function domGlobals(win: Window): Record<string, unknown> {
|
|
74
|
+
return {
|
|
75
|
+
window: win,
|
|
76
|
+
document: win.document,
|
|
77
|
+
navigator: win.navigator,
|
|
78
|
+
Node: win.Node,
|
|
79
|
+
Element: win.Element,
|
|
80
|
+
HTMLElement: win.HTMLElement,
|
|
81
|
+
HTMLInputElement: win.HTMLInputElement,
|
|
82
|
+
Event: win.Event,
|
|
83
|
+
MouseEvent: win.MouseEvent,
|
|
84
|
+
KeyboardEvent: win.KeyboardEvent,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const installed: Record<string, unknown> = {};
|
|
89
|
+
const displaced: Record<string, unknown> = {};
|
|
90
|
+
|
|
91
|
+
beforeAll(async () => {
|
|
92
|
+
const win = new Window({ url: 'http://localhost' });
|
|
93
|
+
for (const [key, value] of Object.entries(domGlobals(win))) {
|
|
94
|
+
displaced[key] = (globalThis as Record<string, unknown>)[key];
|
|
95
|
+
installed[key] = value;
|
|
96
|
+
(globalThis as Record<string, unknown>)[key] = value;
|
|
97
|
+
}
|
|
98
|
+
({ CommandPalette } = await import('@/client/components/workspace/editor/CommandPalette'));
|
|
99
|
+
({ h, render } = await import('preact'));
|
|
100
|
+
({ act } = await import('preact/test-utils'));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterAll(() => {
|
|
104
|
+
for (const key of Object.keys(installed)) {
|
|
105
|
+
if (displaced[key] === undefined) delete (globalThis as Record<string, unknown>)[key];
|
|
106
|
+
else (globalThis as Record<string, unknown>)[key] = displaced[key];
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
beforeEach(() => {
|
|
111
|
+
ran.length = 0;
|
|
112
|
+
closes = 0;
|
|
113
|
+
container = document.createElement('div');
|
|
114
|
+
document.body.appendChild(container);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
render(null, container);
|
|
119
|
+
container.remove();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('CommandPalette', () => {
|
|
123
|
+
test('takes focus on open, so the palette is usable without a click', async () => {
|
|
124
|
+
await mount();
|
|
125
|
+
|
|
126
|
+
expect(document.activeElement).toBe(field());
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('lists the keymap’s commands with their chords', async () => {
|
|
130
|
+
await mount();
|
|
131
|
+
const text = rows().join('\n');
|
|
132
|
+
|
|
133
|
+
expect(text).toContain('Add selection to next find match');
|
|
134
|
+
// The chord is the platform's own. Asserted through the palette's own
|
|
135
|
+
// builder so the expectation is the platform's real answer rather than a
|
|
136
|
+
// guess at which machine the suite is running on — the runner is Linux and a
|
|
137
|
+
// developer's laptop is a Mac, and a Mac-only literal passes on exactly one
|
|
138
|
+
// of them.
|
|
139
|
+
expect(text).toContain(buildPaletteEntries(isMacPlatform()).find((entry) => entry.command === 'selectNextOccurrence')!.chord);
|
|
140
|
+
expect(rows().length).toBeGreaterThan(20);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('every command it lists is one the keymap binds', async () => {
|
|
144
|
+
await mount();
|
|
145
|
+
|
|
146
|
+
// The palette is a VIEW of the keymap; an entry with no binding would be a
|
|
147
|
+
// command that does nothing when it is run.
|
|
148
|
+
for (const entry of buildPaletteEntries(true)) {
|
|
149
|
+
expect(EDITOR_KEY_BINDINGS.some((binding) => binding.command === entry.command)).toBe(true);
|
|
150
|
+
}
|
|
151
|
+
expect(rows().length).toBeGreaterThan(20);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('a query narrows the list', async () => {
|
|
155
|
+
await mount();
|
|
156
|
+
const before = rows().length;
|
|
157
|
+
await typeQuery('occurrence');
|
|
158
|
+
|
|
159
|
+
expect(rows().length).toBeLessThan(before);
|
|
160
|
+
expect(rows().join('\n')).toContain('Select all occurrences');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('a query nothing matches says so instead of listing everything', async () => {
|
|
164
|
+
await mount();
|
|
165
|
+
await typeQuery('zzzz');
|
|
166
|
+
|
|
167
|
+
expect(rows()).toEqual([]);
|
|
168
|
+
expect(container.textContent).toContain('No matching command');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('Enter runs the highlighted command and closes the palette', async () => {
|
|
172
|
+
await mount();
|
|
173
|
+
await typeQuery('toggle line comment');
|
|
174
|
+
await pressKey('Enter');
|
|
175
|
+
|
|
176
|
+
expect(ran).toEqual(['toggleComment']);
|
|
177
|
+
expect(closes).toBe(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('the arrow keys move the highlight, and Enter runs the moved-to command', async () => {
|
|
181
|
+
await mount();
|
|
182
|
+
await typeQuery('occurrence');
|
|
183
|
+
const first = activeRow();
|
|
184
|
+
await pressKey('ArrowDown');
|
|
185
|
+
const second = activeRow();
|
|
186
|
+
|
|
187
|
+
expect(second).not.toBe(first);
|
|
188
|
+
|
|
189
|
+
await pressKey('Enter');
|
|
190
|
+
// Whichever row was highlighted is the one that ran — not the first match.
|
|
191
|
+
expect(ran.length).toBe(1);
|
|
192
|
+
expect(second).toContain(ran[0] === 'selectAllOccurrences' ? 'Select all' : 'Add selection');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('the highlight wraps at the end of the list', async () => {
|
|
196
|
+
await mount();
|
|
197
|
+
await typeQuery('undo');
|
|
198
|
+
const only = activeRow();
|
|
199
|
+
await pressKey('ArrowDown');
|
|
200
|
+
|
|
201
|
+
// One row: stepping wraps onto itself rather than falling off the list.
|
|
202
|
+
expect(activeRow()).toBe(only);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('ArrowUp from the top wraps to the last row', async () => {
|
|
206
|
+
await mount();
|
|
207
|
+
await typeQuery('line');
|
|
208
|
+
const list = rows();
|
|
209
|
+
await pressKey('ArrowUp');
|
|
210
|
+
const active = activeRow();
|
|
211
|
+
|
|
212
|
+
expect(active.length).toBeGreaterThan(0);
|
|
213
|
+
expect(list.length).toBeGreaterThan(1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('Escape closes without running anything', async () => {
|
|
217
|
+
await mount();
|
|
218
|
+
await pressKey('Escape');
|
|
219
|
+
|
|
220
|
+
expect(closes).toBe(1);
|
|
221
|
+
expect(ran).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('clicking a row runs that row’s command', async () => {
|
|
225
|
+
await mount();
|
|
226
|
+
await typeQuery('copy line down');
|
|
227
|
+
const row = container.querySelector('button');
|
|
228
|
+
await act(async () => {
|
|
229
|
+
row?.dispatchEvent(new globalThis.MouseEvent('click', { bubbles: true }));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(ran).toEqual(['copyLineDown']);
|
|
233
|
+
expect(closes).toBe(1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('find-bar commands are marked, so a reader knows where they land', async () => {
|
|
237
|
+
await mount();
|
|
238
|
+
await typeQuery('replace');
|
|
239
|
+
|
|
240
|
+
expect(container.textContent).toContain('find bar');
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The editor's command palette (⌘⇧P).
|
|
8
|
+
*
|
|
9
|
+
* Every editor command this app implements is listed here with the chord that
|
|
10
|
+
* reaches it, both derived from the same keymap table the textarea matches
|
|
11
|
+
* against. The palette is therefore a VIEW of the keymap rather than a second
|
|
12
|
+
* list to keep in sync — a command cannot be listed here and unbound there, and
|
|
13
|
+
* a chord cannot be shown that does not work.
|
|
14
|
+
*
|
|
15
|
+
* Commands this editor deliberately does not have (folding, go-to-definition,
|
|
16
|
+
* the `⌘K` prefix chords) are absent rather than listed and inert: a palette
|
|
17
|
+
* entry that does nothing is worse than no entry, because the user concludes
|
|
18
|
+
* the editor is broken rather than that the feature is missing.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
22
|
+
import type { TargetedKeyboardEvent } from 'preact';
|
|
23
|
+
import { CornerDownLeft, Search } from 'lucide-preact';
|
|
24
|
+
|
|
25
|
+
import { isFindBarCommand, type EditorCommand } from '@/shared/lib/code/editor/keymap';
|
|
26
|
+
import { buildPaletteEntries, filterPaletteEntries, type PaletteEntry } from '@/shared/lib/code/editor/palette';
|
|
27
|
+
import { isMacPlatform } from '@/shared/lib/util/platform';
|
|
28
|
+
|
|
29
|
+
interface CommandPaletteProps {
|
|
30
|
+
onRun: (command: EditorCommand) => void;
|
|
31
|
+
onClose: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function CommandPalette({ onRun, onClose }: CommandPaletteProps) {
|
|
35
|
+
const [query, setQuery] = useState('');
|
|
36
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
37
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
38
|
+
const listRef = useRef<HTMLDivElement | null>(null);
|
|
39
|
+
|
|
40
|
+
// Built once per mount from the keymap, so a filter can never offer something
|
|
41
|
+
// the keymap does not have.
|
|
42
|
+
const entries = useMemo(() => buildPaletteEntries(isMacPlatform()), []);
|
|
43
|
+
const matches = useMemo(() => filterPaletteEntries(entries, query), [entries, query]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
inputRef.current?.focus();
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
// Keep the highlighted row in view while the filter shrinks the list under it.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const list = listRef.current;
|
|
52
|
+
const active = list?.children[activeIndex] as HTMLElement | undefined;
|
|
53
|
+
active?.scrollIntoView({ block: 'nearest' });
|
|
54
|
+
}, [activeIndex, matches.length]);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
setActiveIndex((previous) => Math.min(previous, Math.max(matches.length - 1, 0)));
|
|
58
|
+
}, [matches.length]);
|
|
59
|
+
|
|
60
|
+
const commit = (entry: PaletteEntry | undefined) => {
|
|
61
|
+
if (!entry) return;
|
|
62
|
+
onClose();
|
|
63
|
+
onRun(entry.command);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const handleKeyDown = (event: TargetedKeyboardEvent<HTMLInputElement>) => {
|
|
67
|
+
if (event.key === 'ArrowDown') {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
setActiveIndex((previous) => (matches.length === 0 ? 0 : (previous + 1) % matches.length));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (event.key === 'ArrowUp') {
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
setActiveIndex((previous) => (matches.length === 0 ? 0 : (previous - 1 + matches.length) % matches.length));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (event.key === 'Enter') {
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
commit(matches[activeIndex]);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (event.key === 'Escape') {
|
|
83
|
+
// Handled on the panel's capture phase, but stopping here too keeps the
|
|
84
|
+
// key from reaching the document if the palette is ever mounted alone.
|
|
85
|
+
event.preventDefault();
|
|
86
|
+
event.stopPropagation();
|
|
87
|
+
onClose();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className="absolute inset-0 z-40 flex items-start justify-center pt-12 bg-ink/10" onClick={onClose}>
|
|
93
|
+
<div
|
|
94
|
+
className="w-[520px] max-w-[92%] bg-paper border border-ink/15 rounded-lg shadow-2xl flex flex-col overflow-hidden"
|
|
95
|
+
onClick={(e) => e.stopPropagation()}
|
|
96
|
+
>
|
|
97
|
+
<div className="flex items-center gap-2 px-3 py-2 border-b border-ink/10">
|
|
98
|
+
<Search size={14} className="text-ink/40 flex-shrink-0" />
|
|
99
|
+
<input
|
|
100
|
+
ref={inputRef}
|
|
101
|
+
type="text"
|
|
102
|
+
value={query}
|
|
103
|
+
onInput={(e) => setQuery(e.currentTarget.value)}
|
|
104
|
+
onKeyDown={handleKeyDown}
|
|
105
|
+
placeholder="Type a command or a shortcut"
|
|
106
|
+
aria-label="Command palette"
|
|
107
|
+
className="flex-1 min-w-0 bg-transparent text-xs font-sans text-ink focus:outline-none placeholder-ink/40"
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div ref={listRef} className="max-h-[320px] overflow-y-auto py-1">
|
|
112
|
+
{matches.length === 0 ? (
|
|
113
|
+
<div className="px-3 py-4 text-center text-xs text-ink/40 font-sans">No matching command</div>
|
|
114
|
+
) : (
|
|
115
|
+
matches.map((entry, index) => (
|
|
116
|
+
<button
|
|
117
|
+
key={entry.command}
|
|
118
|
+
type="button"
|
|
119
|
+
onMouseEnter={() => setActiveIndex(index)}
|
|
120
|
+
onClick={() => commit(entry)}
|
|
121
|
+
className={`w-full flex items-center justify-between gap-3 px-3 py-1.5 text-left text-xs font-sans transition-colors ${
|
|
122
|
+
index === activeIndex ? 'bg-ink/5 text-ink' : 'text-ink/80'
|
|
123
|
+
}`}
|
|
124
|
+
>
|
|
125
|
+
<span className="flex items-center gap-2 min-w-0">
|
|
126
|
+
{index === activeIndex ? <CornerDownLeft size={12} className="text-ink/40 flex-shrink-0" /> : <span className="w-3" />}
|
|
127
|
+
<span className="truncate">{entry.label}</span>
|
|
128
|
+
{isFindBarCommand(entry.command) ? <span className="text-[10px] text-ink/40 flex-shrink-0">find bar</span> : null}
|
|
129
|
+
</span>
|
|
130
|
+
<span className="font-mono text-[10px] text-ink/50 flex-shrink-0">{entry.chord}</span>
|
|
131
|
+
</button>
|
|
132
|
+
))
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The find bar's rendering.
|
|
8
|
+
*
|
|
9
|
+
* The arithmetic lives in `shared/lib/code/editor/find-status` and the state in
|
|
10
|
+
* `use-editor-find`; what this covers is the bar itself — that every toggle
|
|
11
|
+
* carries its own `aria-pressed`, that the chord in a tooltip comes from the
|
|
12
|
+
* keymap rather than a hand-written string, and that Enter and the buttons reach
|
|
13
|
+
* the hook rather than doing nothing.
|
|
14
|
+
*
|
|
15
|
+
* Rendered directly with `h()` (no JSX) against happy-dom; the component modules
|
|
16
|
+
* are imported dynamically, after those globals exist.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
20
|
+
import { Window } from 'happy-dom';
|
|
21
|
+
import type { ComponentChildren, h as PreactH, render as PreactRender } from 'preact';
|
|
22
|
+
import type { act as PreactAct } from 'preact/test-utils';
|
|
23
|
+
import type { EditorFindState } from '@/client/hooks/editor/use-editor-find';
|
|
24
|
+
|
|
25
|
+
let FindWidget: (props: { find: EditorFindState }) => ComponentChildren;
|
|
26
|
+
let h: typeof PreactH;
|
|
27
|
+
let render: typeof PreactRender;
|
|
28
|
+
let act: typeof PreactAct;
|
|
29
|
+
|
|
30
|
+
let container: HTMLElement;
|
|
31
|
+
/** Calls the widget made into the hook, so the wiring is observable. */
|
|
32
|
+
const calls: string[] = [];
|
|
33
|
+
|
|
34
|
+
/** A find state whose every action records itself, so a click is provable. */
|
|
35
|
+
function findState(overrides: Partial<EditorFindState> = {}): EditorFindState {
|
|
36
|
+
return {
|
|
37
|
+
open: true,
|
|
38
|
+
replaceOpen: false,
|
|
39
|
+
query: '',
|
|
40
|
+
replacement: '',
|
|
41
|
+
options: { matchCase: false, wholeWord: false, isRegex: false },
|
|
42
|
+
matches: [],
|
|
43
|
+
truncated: false,
|
|
44
|
+
invalid: false,
|
|
45
|
+
currentIndex: -1,
|
|
46
|
+
setQuery: (value) => calls.push(`setQuery:${value}`),
|
|
47
|
+
setReplacement: (value) => calls.push(`setReplacement:${value}`),
|
|
48
|
+
openFind: () => calls.push('openFind'),
|
|
49
|
+
openReplace: () => calls.push('openReplace'),
|
|
50
|
+
toggleReplace: () => calls.push('toggleReplace'),
|
|
51
|
+
focusRequest: 0,
|
|
52
|
+
close: () => calls.push('close'),
|
|
53
|
+
runCommand: (command) => calls.push(`runCommand:${command}`),
|
|
54
|
+
toggleOption: (key) => calls.push(`toggleOption:${key}`),
|
|
55
|
+
step: (direction) => calls.push(`step:${direction}`),
|
|
56
|
+
replaceCurrent: () => calls.push('replaceCurrent'),
|
|
57
|
+
replaceAll: () => calls.push('replaceAll'),
|
|
58
|
+
...overrides,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function mount(find: EditorFindState) {
|
|
63
|
+
await act(async () => {
|
|
64
|
+
render(h(FindWidget, { find }), container);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function byLabel(label: string): HTMLElement | null {
|
|
69
|
+
return container.querySelector(`[aria-label="${label}"]`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The BUTTON with this label — `Replace` also names the text field. */
|
|
73
|
+
function buttonByLabel(label: string): HTMLButtonElement | null {
|
|
74
|
+
return container.querySelector(`button[aria-label="${label}"]`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The element whose label starts with `prefix` — the toggles carry a chord suffix. */
|
|
78
|
+
function byLabelPrefix(prefix: string): HTMLElement | null {
|
|
79
|
+
return container.querySelector(`[aria-label^="${prefix}"]`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function click(element: HTMLElement | null) {
|
|
83
|
+
await act(async () => {
|
|
84
|
+
element?.dispatchEvent(new globalThis.MouseEvent('click', { bubbles: true }));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Type into a field the way the browser reports it. */
|
|
89
|
+
async function type(input: HTMLElement | null, value: string) {
|
|
90
|
+
if (!input) return;
|
|
91
|
+
(input as HTMLInputElement).value = value;
|
|
92
|
+
await act(async () => {
|
|
93
|
+
input.dispatchEvent(new globalThis.Event('input', { bubbles: true }));
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function press(input: HTMLElement | null, key: string, shiftKey = false) {
|
|
98
|
+
await act(async () => {
|
|
99
|
+
input?.dispatchEvent(new globalThis.KeyboardEvent('keydown', { key, shiftKey, bubbles: true }));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function domGlobals(win: Window): Record<string, unknown> {
|
|
104
|
+
return {
|
|
105
|
+
window: win,
|
|
106
|
+
document: win.document,
|
|
107
|
+
navigator: win.navigator,
|
|
108
|
+
Node: win.Node,
|
|
109
|
+
Element: win.Element,
|
|
110
|
+
HTMLElement: win.HTMLElement,
|
|
111
|
+
HTMLInputElement: win.HTMLInputElement,
|
|
112
|
+
Event: win.Event,
|
|
113
|
+
MouseEvent: win.MouseEvent,
|
|
114
|
+
KeyboardEvent: win.KeyboardEvent,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const installed: Record<string, unknown> = {};
|
|
119
|
+
const displaced: Record<string, unknown> = {};
|
|
120
|
+
|
|
121
|
+
beforeAll(async () => {
|
|
122
|
+
const win = new Window({ url: 'http://localhost' });
|
|
123
|
+
for (const [key, value] of Object.entries(domGlobals(win))) {
|
|
124
|
+
displaced[key] = (globalThis as Record<string, unknown>)[key];
|
|
125
|
+
installed[key] = value;
|
|
126
|
+
(globalThis as Record<string, unknown>)[key] = value;
|
|
127
|
+
}
|
|
128
|
+
({ FindWidget } = await import('@/client/components/workspace/editor/FindWidget'));
|
|
129
|
+
({ h, render } = await import('preact'));
|
|
130
|
+
({ act } = await import('preact/test-utils'));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
afterAll(() => {
|
|
134
|
+
for (const key of Object.keys(installed)) {
|
|
135
|
+
if (displaced[key] === undefined) delete (globalThis as Record<string, unknown>)[key];
|
|
136
|
+
else (globalThis as Record<string, unknown>)[key] = displaced[key];
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
calls.length = 0;
|
|
142
|
+
container = document.createElement('div');
|
|
143
|
+
document.body.appendChild(container);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
afterEach(() => {
|
|
147
|
+
render(null, container);
|
|
148
|
+
container.remove();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('FindWidget', () => {
|
|
152
|
+
test('focuses the query field on mount, so ⌘F needs no second click', async () => {
|
|
153
|
+
await mount(findState());
|
|
154
|
+
|
|
155
|
+
expect(document.activeElement).toBe(byLabel('Find'));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('typing in the query reaches the hook', async () => {
|
|
159
|
+
await mount(findState());
|
|
160
|
+
await type(byLabel('Find'), 'alpha');
|
|
161
|
+
|
|
162
|
+
expect(calls).toEqual(['setQuery:alpha']);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('the replace row is hidden until it is asked for', async () => {
|
|
166
|
+
await mount(findState());
|
|
167
|
+
|
|
168
|
+
expect(byLabel('Replace')).toBeNull();
|
|
169
|
+
expect(byLabelPrefix('Show replace')).not.toBeNull();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('with the replace row open, typing the replacement reaches the hook', async () => {
|
|
173
|
+
await mount(findState({ replaceOpen: true }));
|
|
174
|
+
await type(byLabel('Replace'), 'OMEGA');
|
|
175
|
+
|
|
176
|
+
expect(calls).toEqual(['setReplacement:OMEGA']);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('each toggle reports its own state and its own shortcut', async () => {
|
|
180
|
+
await mount(findState({ options: { matchCase: true, wholeWord: false, isRegex: true } }));
|
|
181
|
+
|
|
182
|
+
const matchCase = byLabelPrefix('Match case');
|
|
183
|
+
const wholeWord = byLabelPrefix('Match whole word');
|
|
184
|
+
const regex = byLabelPrefix('Use regular expression');
|
|
185
|
+
|
|
186
|
+
expect(matchCase?.getAttribute('aria-pressed')).toBe('true');
|
|
187
|
+
expect(wholeWord?.getAttribute('aria-pressed')).toBe('false');
|
|
188
|
+
expect(regex?.getAttribute('aria-pressed')).toBe('true');
|
|
189
|
+
// The chord is derived from the keymap, so a tooltip cannot name a key that
|
|
190
|
+
// is not bound.
|
|
191
|
+
expect(matchCase?.getAttribute('aria-label')).toMatch(/[⌘⌥⇧]|Alt\+/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('clicking a toggle names the option it flips', async () => {
|
|
195
|
+
await mount(findState());
|
|
196
|
+
await click(byLabelPrefix('Match whole word'));
|
|
197
|
+
|
|
198
|
+
expect(calls).toEqual(['toggleOption:wholeWord']);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('Enter steps forward and ⇧Enter steps back, keeping focus in the field', async () => {
|
|
202
|
+
await mount(findState({ matches: [{ start: 0, end: 5 }], currentIndex: 0 }));
|
|
203
|
+
|
|
204
|
+
await press(byLabel('Find'), 'Enter');
|
|
205
|
+
await press(byLabel('Find'), 'Enter', true);
|
|
206
|
+
|
|
207
|
+
expect(calls).toEqual(['step:1', 'step:-1']);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('a chorded Enter is left to the panel, which owns ⌘⏎', async () => {
|
|
211
|
+
await mount(findState({ matches: [{ start: 0, end: 5 }], currentIndex: 0 }));
|
|
212
|
+
const input = byLabel('Find');
|
|
213
|
+
await act(async () => {
|
|
214
|
+
input?.dispatchEvent(new globalThis.KeyboardEvent('keydown', { key: 'Enter', metaKey: true, bubbles: true }));
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(calls).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('the stepper buttons are disabled with nothing to step to', async () => {
|
|
221
|
+
await mount(findState());
|
|
222
|
+
|
|
223
|
+
expect(buttonByLabel('Next match')?.disabled).toBe(true);
|
|
224
|
+
expect(buttonByLabel('Previous match')?.disabled).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('the replace buttons are disabled with nothing to replace', async () => {
|
|
228
|
+
await mount(findState({ replaceOpen: true }));
|
|
229
|
+
|
|
230
|
+
expect(buttonByLabel('Replace')?.disabled).toBe(true);
|
|
231
|
+
expect(buttonByLabel('Replace all')?.disabled).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('the count is announced and reads as the widget’s own status', async () => {
|
|
235
|
+
await mount(findState({ query: 'alpha', matches: [{ start: 0, end: 5 }, { start: 9, end: 14 }], currentIndex: 1 }));
|
|
236
|
+
|
|
237
|
+
const status = container.querySelector('[aria-live="polite"]');
|
|
238
|
+
expect(status?.textContent).toBe('2 of 2');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('an invalid pattern is marked on the field, not only in the status', async () => {
|
|
242
|
+
// The user has to see WHICH field is wrong, not just that something is.
|
|
243
|
+
await mount(findState({ query: 'a(', invalid: true }));
|
|
244
|
+
|
|
245
|
+
expect(byLabel('Find')?.className).toContain('border-error');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('the close button asks the hook to close', async () => {
|
|
249
|
+
await mount(findState());
|
|
250
|
+
await click(buttonByLabel('Close find'));
|
|
251
|
+
|
|
252
|
+
expect(calls).toEqual(['close']);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('the expander toggles the replace row', async () => {
|
|
256
|
+
await mount(findState());
|
|
257
|
+
await click(byLabelPrefix('Show replace'));
|
|
258
|
+
|
|
259
|
+
expect(calls).toEqual(['toggleReplace']);
|
|
260
|
+
});
|
|
261
|
+
});
|