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,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `useOccurrences` — the ranges behind ⌘D, ⌘⇧L and ⌘⇧D.
|
|
8
|
+
*
|
|
9
|
+
* The rules live in `shared/lib/code/editor/occurrences` and are tested there;
|
|
10
|
+
* what this file covers is the hook's own contract: the caller's copy wins
|
|
11
|
+
* (clearing the ranges in the panel must clear them here), every change is
|
|
12
|
+
* announced so the surface repaints, and the textarea is read rather than
|
|
13
|
+
* assumed.
|
|
14
|
+
*
|
|
15
|
+
* Runs against a real DOM (happy-dom) because it is a Preact hook; the modules
|
|
16
|
+
* below 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 { h as PreactH, render as PreactRender } from 'preact';
|
|
22
|
+
import type { act as PreactAct } from 'preact/test-utils';
|
|
23
|
+
import type { OccurrencesApi, useOccurrences as UseOccurrencesHook } from '@/client/hooks/editor/use-occurrences';
|
|
24
|
+
import type { TextRange } from '@/shared/lib/code/editor/commands';
|
|
25
|
+
|
|
26
|
+
let useOccurrences: typeof UseOccurrencesHook;
|
|
27
|
+
let h: typeof PreactH;
|
|
28
|
+
let render: typeof PreactRender;
|
|
29
|
+
let act: typeof PreactAct;
|
|
30
|
+
|
|
31
|
+
let api: OccurrencesApi | null = null;
|
|
32
|
+
let container: HTMLElement;
|
|
33
|
+
let textarea: HTMLTextAreaElement;
|
|
34
|
+
/** What the caller passes in as its own copy, changed per test. */
|
|
35
|
+
let controlled: TextRange[] | undefined;
|
|
36
|
+
const announced: Array<readonly TextRange[]> = [];
|
|
37
|
+
const selections: Array<[number, number]> = [];
|
|
38
|
+
|
|
39
|
+
function Harness() {
|
|
40
|
+
api = useOccurrences({
|
|
41
|
+
controlled,
|
|
42
|
+
onChange: (ranges) => announced.push(ranges),
|
|
43
|
+
});
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function flush() {
|
|
48
|
+
for (let i = 0; i < 3; i++) {
|
|
49
|
+
await act(async () => {
|
|
50
|
+
for (let turn = 0; turn < 4; turn++) await Promise.resolve();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function rerender() {
|
|
56
|
+
await act(async () => {
|
|
57
|
+
render(h(Harness, {}), container);
|
|
58
|
+
});
|
|
59
|
+
await flush();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function select(start: number, end = start) {
|
|
63
|
+
textarea.selectionStart = start;
|
|
64
|
+
textarea.selectionEnd = end;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Stands in for the editor's own `select`: it MOVES the textarea's selection and
|
|
69
|
+
* records it. The hook reads the textarea, so a callback that only recorded the
|
|
70
|
+
* range would leave every later press looking at the original caret.
|
|
71
|
+
*/
|
|
72
|
+
function selectingSurface() {
|
|
73
|
+
return (start: number, end: number) => {
|
|
74
|
+
selections.push([start, end]);
|
|
75
|
+
select(start, end);
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function domGlobals(win: Window): Record<string, unknown> {
|
|
80
|
+
return {
|
|
81
|
+
window: win,
|
|
82
|
+
document: win.document,
|
|
83
|
+
navigator: win.navigator,
|
|
84
|
+
Node: win.Node,
|
|
85
|
+
Element: win.Element,
|
|
86
|
+
HTMLElement: win.HTMLElement,
|
|
87
|
+
HTMLTextAreaElement: win.HTMLTextAreaElement,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const installed: Record<string, unknown> = {};
|
|
92
|
+
const displaced: Record<string, unknown> = {};
|
|
93
|
+
|
|
94
|
+
beforeAll(async () => {
|
|
95
|
+
const win = new Window({ url: 'http://localhost' });
|
|
96
|
+
for (const [key, value] of Object.entries(domGlobals(win))) {
|
|
97
|
+
displaced[key] = (globalThis as Record<string, unknown>)[key];
|
|
98
|
+
installed[key] = value;
|
|
99
|
+
(globalThis as Record<string, unknown>)[key] = value;
|
|
100
|
+
}
|
|
101
|
+
({ useOccurrences } = await import('@/client/hooks/editor/use-occurrences'));
|
|
102
|
+
({ h, render } = await import('preact'));
|
|
103
|
+
({ act } = await import('preact/test-utils'));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterAll(() => {
|
|
107
|
+
for (const key of Object.keys(installed)) {
|
|
108
|
+
if (displaced[key] === undefined) delete (globalThis as Record<string, unknown>)[key];
|
|
109
|
+
else (globalThis as Record<string, unknown>)[key] = displaced[key];
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
beforeEach(async () => {
|
|
114
|
+
api = null;
|
|
115
|
+
controlled = undefined;
|
|
116
|
+
announced.length = 0;
|
|
117
|
+
selections.length = 0;
|
|
118
|
+
container = document.createElement('div');
|
|
119
|
+
document.body.appendChild(container);
|
|
120
|
+
textarea = document.createElement('textarea');
|
|
121
|
+
textarea.value = 'alpha beta alpha gamma alpha';
|
|
122
|
+
container.appendChild(textarea);
|
|
123
|
+
await rerender();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
afterEach(() => {
|
|
127
|
+
render(null, container);
|
|
128
|
+
container.remove();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('useOccurrences', () => {
|
|
132
|
+
test('starts with no extra ranges', () => {
|
|
133
|
+
expect(api?.ranges).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('⌘D selects the word first, then grows one occurrence per press', async () => {
|
|
137
|
+
select(2);
|
|
138
|
+
await act(async () => {
|
|
139
|
+
api?.selectNext(textarea, selectingSurface());
|
|
140
|
+
});
|
|
141
|
+
expect(selections.at(-1)).toEqual([0, 5]);
|
|
142
|
+
expect(api?.ranges).toEqual([]);
|
|
143
|
+
|
|
144
|
+
await act(async () => {
|
|
145
|
+
api?.selectNext(textarea, selectingSurface());
|
|
146
|
+
});
|
|
147
|
+
expect(api?.ranges).toEqual([{ start: 11, end: 16 }]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('⌘⇧L takes every occurrence, primary first', async () => {
|
|
151
|
+
select(0, 5);
|
|
152
|
+
await act(async () => {
|
|
153
|
+
api?.selectAll(textarea, selectingSurface());
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(selections.at(-1)).toEqual([0, 5]);
|
|
157
|
+
expect(api?.ranges).toEqual([
|
|
158
|
+
{ start: 11, end: 16 },
|
|
159
|
+
{ start: 23, end: 28 },
|
|
160
|
+
]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('every change is announced so the surface can repaint', async () => {
|
|
164
|
+
select(0, 5);
|
|
165
|
+
await act(async () => {
|
|
166
|
+
api?.selectAll(textarea, () => {});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(announced.at(-1)?.length).toBe(2);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('clear drops every range and says so', async () => {
|
|
173
|
+
select(0, 5);
|
|
174
|
+
await act(async () => {
|
|
175
|
+
api?.selectAll(textarea, () => {});
|
|
176
|
+
});
|
|
177
|
+
await act(async () => {
|
|
178
|
+
api?.clear();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(api?.ranges).toEqual([]);
|
|
182
|
+
expect(announced.at(-1)).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('the caller’s copy is authoritative', async () => {
|
|
186
|
+
// The panel can clear the ranges without the editor asking (a file switch,
|
|
187
|
+
// a save); the hook has to follow, not keep its own stale list.
|
|
188
|
+
controlled = [{ start: 1, end: 3 }];
|
|
189
|
+
await rerender();
|
|
190
|
+
|
|
191
|
+
expect(api?.ranges).toEqual([{ start: 1, end: 3 }]);
|
|
192
|
+
|
|
193
|
+
controlled = [];
|
|
194
|
+
await rerender();
|
|
195
|
+
|
|
196
|
+
expect(api?.ranges).toEqual([]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('⌘⇧D adds a caret below on the same column', async () => {
|
|
200
|
+
textarea.value = 'one\ntwo\nthree';
|
|
201
|
+
select(1);
|
|
202
|
+
await act(async () => {
|
|
203
|
+
api?.insertCursorBelow(textarea);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(api?.ranges).toEqual([{ start: 5, end: 5 }]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('⌘⇧D on the last line adds nothing', async () => {
|
|
210
|
+
textarea.value = 'one\ntwo';
|
|
211
|
+
select(5);
|
|
212
|
+
await act(async () => {
|
|
213
|
+
api?.insertCursorBelow(textarea);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
expect(api?.ranges).toEqual([]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('replicate reports nothing without extra ranges', () => {
|
|
220
|
+
expect(api?.replicate('abc', 'abXc', { start: 2, end: 2 })).toBeNull();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('replicate replays the browser’s edit at every extra range', async () => {
|
|
224
|
+
select(0, 5);
|
|
225
|
+
await act(async () => {
|
|
226
|
+
api?.selectAll(textarea, () => {});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// The browser replaced `alpha` at 0..5 with `Z`; the other two follow.
|
|
230
|
+
const applied = api?.replicate('alpha beta alpha gamma alpha', 'Z beta alpha gamma alpha', { start: 0, end: 5 });
|
|
231
|
+
|
|
232
|
+
expect(applied?.value).toBe('Z beta Z gamma Z');
|
|
233
|
+
expect(applied?.caret).toBe(1);
|
|
234
|
+
expect(applied?.rest.length).toBe(2);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The hook that owns the editor's extra selection ranges.
|
|
8
|
+
*
|
|
9
|
+
* It is a thin shell over `shared/lib/code/editor/occurrences`: the rules live
|
|
10
|
+
* there as pure functions, and this holds the ranges in a ref (the keydown and
|
|
11
|
+
* `input` handlers read them synchronously, inside one event) and tells the
|
|
12
|
+
* caller through `onChange` so the surface can paint them.
|
|
13
|
+
*
|
|
14
|
+
* The split is deliberate: a hook that decides where a range goes cannot be
|
|
15
|
+
* tested without a DOM, and every rule here is one a user notices when it is
|
|
16
|
+
* wrong.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useRef } from 'preact/hooks';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
growToNextOccurrence,
|
|
23
|
+
offsetBelow,
|
|
24
|
+
replicateAtRanges,
|
|
25
|
+
selectAllOccurrences,
|
|
26
|
+
} from '@/shared/lib/code/editor/occurrences';
|
|
27
|
+
import type { TextRange } from '@/shared/lib/code/editor/commands';
|
|
28
|
+
|
|
29
|
+
export interface UseOccurrencesOptions {
|
|
30
|
+
/** Called with the extra ranges whenever they change, so the surface repaints. */
|
|
31
|
+
onChange: (ranges: readonly TextRange[]) => void;
|
|
32
|
+
/** The caller's copy of the ranges — clearing them there must clear them here. */
|
|
33
|
+
controlled?: readonly TextRange[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface OccurrencesApi {
|
|
37
|
+
/** The extra ranges, excluding the textarea's own (primary) selection. */
|
|
38
|
+
readonly ranges: readonly TextRange[];
|
|
39
|
+
/** Replace the ranges and notify the surface. */
|
|
40
|
+
set: (ranges: readonly TextRange[]) => void;
|
|
41
|
+
/** Drop every extra range. */
|
|
42
|
+
clear: () => void;
|
|
43
|
+
/** ⌘D — grow the primary selection to the next occurrence of what it covers. */
|
|
44
|
+
selectNext: (input: HTMLTextAreaElement, select: (start: number, end: number) => void) => void;
|
|
45
|
+
/** ⌘⇧L — select every occurrence of the selection/word at the caret. */
|
|
46
|
+
selectAll: (input: HTMLTextAreaElement, select: (start: number, end: number) => void) => void;
|
|
47
|
+
/** ⌘⇧D — add an empty caret on the line below. */
|
|
48
|
+
insertCursorBelow: (input: HTMLTextAreaElement) => void;
|
|
49
|
+
/**
|
|
50
|
+
* Replay the browser's edit at every extra range. Returns the buffer and
|
|
51
|
+
* caret the caller should commit, or null when there is nothing to replicate.
|
|
52
|
+
*/
|
|
53
|
+
replicate: (before: string, after: string, primaryBefore: TextRange) => { value: string; caret: number; rest: TextRange[] } | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useOccurrences({ onChange, controlled }: UseOccurrencesOptions): OccurrencesApi {
|
|
57
|
+
const ref = useRef<readonly TextRange[]>([]);
|
|
58
|
+
// The caller may clear the ranges without the editor asking (a file switch, a
|
|
59
|
+
// save), so its copy is mirrored into the ref the handlers read.
|
|
60
|
+
if (controlled && controlled !== ref.current) ref.current = controlled;
|
|
61
|
+
|
|
62
|
+
const set = (ranges: readonly TextRange[]) => {
|
|
63
|
+
ref.current = ranges;
|
|
64
|
+
onChange(ranges);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
get ranges() {
|
|
69
|
+
return ref.current;
|
|
70
|
+
},
|
|
71
|
+
set,
|
|
72
|
+
clear: () => set([]),
|
|
73
|
+
|
|
74
|
+
selectNext: (input, select) => {
|
|
75
|
+
const current: TextRange = { start: input.selectionStart, end: input.selectionEnd };
|
|
76
|
+
const next = growToNextOccurrence(input.value, current, ref.current);
|
|
77
|
+
if (!next) return;
|
|
78
|
+
select(next.primary.start, next.primary.end);
|
|
79
|
+
set(next.extras);
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
selectAll: (input, select) => {
|
|
83
|
+
const current: TextRange = { start: input.selectionStart, end: input.selectionEnd };
|
|
84
|
+
const all = selectAllOccurrences(input.value, current);
|
|
85
|
+
if (!all) return;
|
|
86
|
+
select(all.primary.start, all.primary.end);
|
|
87
|
+
set(all.extras);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
insertCursorBelow: (input) => {
|
|
91
|
+
const target = offsetBelow(input.value, input.selectionStart);
|
|
92
|
+
if (target === null) return;
|
|
93
|
+
set([...ref.current, { start: target, end: target }]);
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
replicate: (before, after, primaryBefore) => replicateAtRanges(before, after, primaryBefore, ref.current),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `useFileActions` — the Files panel's server contract.
|
|
8
|
+
*
|
|
9
|
+
* Three rules are asserted here because each was a bug: a create that SUCCEEDS
|
|
10
|
+
* opens the new file (the dialog used to close and leave the user hunting for
|
|
11
|
+
* an empty row), a create that is REFUSED keeps the dialog open with the reason
|
|
12
|
+
* beside the field (rather than closing as if it had worked), and a settled
|
|
13
|
+
* response is acted on exactly ONCE — an unguarded effect re-ran on every render
|
|
14
|
+
* and pinned the tab the moment a file was created.
|
|
15
|
+
*
|
|
16
|
+
* Runs against a real DOM (happy-dom) because it is a Preact hook driving
|
|
17
|
+
* fetches. The modules below are therefore imported DYNAMICALLY, inside
|
|
18
|
+
* `beforeAll`: Preact binds its environment at evaluation time, so a static
|
|
19
|
+
* import would capture the DOM-less one and the render would fail — the same
|
|
20
|
+
* boundary `use-file-editor.test.ts` documents.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
24
|
+
import { Window } from 'happy-dom';
|
|
25
|
+
import type { h as PreactH, render as PreactRender } from 'preact';
|
|
26
|
+
import type { act as PreactAct } from 'preact/test-utils';
|
|
27
|
+
import type { FileActions, useFileActions as UseFileActionsHook } from '@/client/hooks/workspace/file-tree-actions';
|
|
28
|
+
|
|
29
|
+
let useFileActions: typeof UseFileActionsHook;
|
|
30
|
+
let h: typeof PreactH;
|
|
31
|
+
let render: typeof PreactRender;
|
|
32
|
+
let act: typeof PreactAct;
|
|
33
|
+
|
|
34
|
+
let mounted: FileActions | null = null;
|
|
35
|
+
let container: HTMLElement;
|
|
36
|
+
|
|
37
|
+
/** What the stubbed endpoint was asked, and what it answers. */
|
|
38
|
+
interface Server {
|
|
39
|
+
requests: Array<Record<string, string>>;
|
|
40
|
+
/** Response for the next create: `success` with a path, or a refusal. */
|
|
41
|
+
createResult: { success?: boolean; path?: string; error?: string };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const opened: Array<{ path: string; name: string }> = [];
|
|
45
|
+
let refreshes = 0;
|
|
46
|
+
|
|
47
|
+
function stubFetch(server: Server) {
|
|
48
|
+
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
49
|
+
const form = init?.body as FormData;
|
|
50
|
+
const fields: Record<string, string> = {};
|
|
51
|
+
for (const [key, value] of form.entries()) fields[key] = String(value);
|
|
52
|
+
server.requests.push(fields);
|
|
53
|
+
// The fetcher reads `text()` and parses it itself, so a stub that only
|
|
54
|
+
// offers `json()` would leave every response looking empty — the request
|
|
55
|
+
// would be recorded and the effect would never see an answer.
|
|
56
|
+
const body = JSON.stringify(server.createResult);
|
|
57
|
+
return { ok: true, text: async () => body };
|
|
58
|
+
}) as unknown as typeof fetch;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Let the request chain and the render it queues land.
|
|
63
|
+
*
|
|
64
|
+
* Several `act` boundaries, each draining microtasks: the fetcher's chain
|
|
65
|
+
* (`fetch` → `text()` → parse → `setData` → `setState`) resolves over several
|
|
66
|
+
* turns, and a state update queued outside an `act` boundary is only flushed
|
|
67
|
+
* when the next one arrives. No timers — the hook resolves on promises, so
|
|
68
|
+
* waiting on the clock would only be guessing at how many turns it needs.
|
|
69
|
+
*/
|
|
70
|
+
async function settle() {
|
|
71
|
+
for (let i = 0; i < 8; i++) {
|
|
72
|
+
await act(async () => {
|
|
73
|
+
for (let turn = 0; turn < 8; turn++) await Promise.resolve();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function mount(file: { path: string; name: string }, diff?: { staged?: boolean; status?: string }) {
|
|
79
|
+
const Probe = () => {
|
|
80
|
+
mounted = useFileActions({
|
|
81
|
+
// `basePath` is the ABSOLUTE listing root the server reported, and it is
|
|
82
|
+
// what Copy Path anchors on — `rootPath` is the client's own (possibly
|
|
83
|
+
// `~`-relative) workspace path, which is not resolvable outside the panel.
|
|
84
|
+
file: { ...file, basePath: '/root', rootPath: '/root', repo: '.' },
|
|
85
|
+
diff,
|
|
86
|
+
onOpenFile: (target) => opened.push({ path: target.path, name: target.name }),
|
|
87
|
+
onActionComplete: () => refreshes++,
|
|
88
|
+
});
|
|
89
|
+
return null;
|
|
90
|
+
};
|
|
91
|
+
return act(async () => {
|
|
92
|
+
render(h(Probe, {}), container);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Open the create dialog the way the context menu does. */
|
|
97
|
+
async function openCreateDialog() {
|
|
98
|
+
await act(async () => {
|
|
99
|
+
mounted?.handleAction('new_file');
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Submit the create dialog with `name` and let the response land — the whole
|
|
105
|
+
* user sequence (open the dialog, type, press Create), because the state under
|
|
106
|
+
* test is what survives it.
|
|
107
|
+
*/
|
|
108
|
+
async function createNamed(name: string) {
|
|
109
|
+
await openCreateDialog();
|
|
110
|
+
await act(async () => {
|
|
111
|
+
mounted?.setCreateName(name);
|
|
112
|
+
});
|
|
113
|
+
await act(async () => {
|
|
114
|
+
mounted?.submitCreate({ preventDefault() {} } as never);
|
|
115
|
+
});
|
|
116
|
+
await settle();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function domGlobals(win: Window): Record<string, unknown> {
|
|
120
|
+
return {
|
|
121
|
+
window: win,
|
|
122
|
+
document: win.document,
|
|
123
|
+
navigator: win.navigator,
|
|
124
|
+
Node: win.Node,
|
|
125
|
+
Element: win.Element,
|
|
126
|
+
HTMLElement: win.HTMLElement,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const installed: Record<string, unknown> = {};
|
|
131
|
+
const displaced: Record<string, unknown> = {};
|
|
132
|
+
|
|
133
|
+
beforeAll(async () => {
|
|
134
|
+
const win = new Window({ url: 'http://localhost' });
|
|
135
|
+
for (const [key, value] of Object.entries(domGlobals(win))) {
|
|
136
|
+
displaced[key] = (globalThis as Record<string, unknown>)[key];
|
|
137
|
+
installed[key] = value;
|
|
138
|
+
(globalThis as Record<string, unknown>)[key] = value;
|
|
139
|
+
}
|
|
140
|
+
({ useFileActions } = await import('@/client/hooks/workspace/file-tree-actions'));
|
|
141
|
+
({ h, render } = await import('preact'));
|
|
142
|
+
({ act } = await import('preact/test-utils'));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
afterAll(() => {
|
|
146
|
+
for (const key of Object.keys(installed)) {
|
|
147
|
+
if (displaced[key] === undefined) delete (globalThis as Record<string, unknown>)[key];
|
|
148
|
+
else (globalThis as Record<string, unknown>)[key] = displaced[key];
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
beforeEach(() => {
|
|
153
|
+
mounted = null;
|
|
154
|
+
opened.length = 0;
|
|
155
|
+
refreshes = 0;
|
|
156
|
+
container = document.createElement('div');
|
|
157
|
+
document.body.appendChild(container);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
afterEach(() => {
|
|
161
|
+
render(null, container);
|
|
162
|
+
container.remove();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
function server(createResult: Server['createResult']): Server {
|
|
166
|
+
return { requests: [], createResult };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
describe('useFileActions create', () => {
|
|
170
|
+
test('a created file opens in the editor and refreshes the tree', async () => {
|
|
171
|
+
const srv = server({ success: true, path: 'src/new-file.ts' });
|
|
172
|
+
stubFetch(srv);
|
|
173
|
+
await mount({ path: 'src', name: 'src' });
|
|
174
|
+
await createNamed('new-file.ts');
|
|
175
|
+
|
|
176
|
+
expect(srv.requests[0]).toEqual({ actionType: 'create', path: 'src', name: 'new-file.ts', root: '/root' });
|
|
177
|
+
expect(opened).toEqual([{ path: 'src/new-file.ts', name: 'new-file.ts' }]);
|
|
178
|
+
expect(refreshes).toBe(1);
|
|
179
|
+
expect(mounted?.showCreateModal).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('a refusal keeps the dialog open and reports the server’s reason', async () => {
|
|
183
|
+
stubFetch(server({ error: 'new-file.ts already exists' }));
|
|
184
|
+
await mount({ path: 'src', name: 'src' });
|
|
185
|
+
await createNamed('new-file.ts');
|
|
186
|
+
|
|
187
|
+
expect(mounted?.showCreateModal).toBe(true);
|
|
188
|
+
expect(mounted?.createError).toBe('new-file.ts already exists');
|
|
189
|
+
expect(opened).toEqual([]);
|
|
190
|
+
expect(refreshes).toBe(0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('a refusal with no message still says something', async () => {
|
|
194
|
+
stubFetch(server({}));
|
|
195
|
+
await mount({ path: 'src', name: 'src' });
|
|
196
|
+
await createNamed('new-file.ts');
|
|
197
|
+
|
|
198
|
+
expect(mounted?.createError).toBe('Could not create the file');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('a settled response is acted on once, however many times the row re-renders', async () => {
|
|
202
|
+
// The unguarded version re-ran this effect on every render and re-opened
|
|
203
|
+
// the file forever, which pinned the tab.
|
|
204
|
+
const srv = server({ success: true, path: 'src/once.ts' });
|
|
205
|
+
stubFetch(srv);
|
|
206
|
+
await mount({ path: 'src', name: 'src' });
|
|
207
|
+
await createNamed('once.ts');
|
|
208
|
+
|
|
209
|
+
for (let i = 0; i < 3; i++) {
|
|
210
|
+
await act(async () => {
|
|
211
|
+
mounted?.setCreateName('');
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
await settle();
|
|
215
|
+
|
|
216
|
+
expect(refreshes).toBe(1);
|
|
217
|
+
expect(opened.length).toBe(1);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('an empty name never reaches the server', async () => {
|
|
221
|
+
const srv = server({ success: true, path: 'src/x.ts' });
|
|
222
|
+
stubFetch(srv);
|
|
223
|
+
await mount({ path: 'src', name: 'src' });
|
|
224
|
+
|
|
225
|
+
await act(async () => {
|
|
226
|
+
mounted?.setCreateName(' ');
|
|
227
|
+
});
|
|
228
|
+
await act(async () => {
|
|
229
|
+
mounted?.submitCreate({ preventDefault() {} } as never);
|
|
230
|
+
});
|
|
231
|
+
await settle();
|
|
232
|
+
|
|
233
|
+
expect(srv.requests).toEqual([]);
|
|
234
|
+
expect(mounted?.showCreateModal).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('useFileActions context menu', () => {
|
|
239
|
+
test('opening the create dialog resets the previous attempt', async () => {
|
|
240
|
+
stubFetch(server({ error: 'taken' }));
|
|
241
|
+
await mount({ path: 'src', name: 'src' });
|
|
242
|
+
await createNamed('taken.ts');
|
|
243
|
+
expect(mounted?.createError).toBe('taken');
|
|
244
|
+
|
|
245
|
+
await act(async () => {
|
|
246
|
+
mounted?.handleAction('new_file');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(mounted?.showCreateModal).toBe(true);
|
|
250
|
+
expect(mounted?.createError).toBeNull();
|
|
251
|
+
expect(mounted?.createName).toBe('');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('copying the path reports the ABSOLUTE path, and does not call the server', async () => {
|
|
255
|
+
const srv = server({});
|
|
256
|
+
stubFetch(srv);
|
|
257
|
+
const writes: string[] = [];
|
|
258
|
+
// `copyToClipboard` prefers the async clipboard API, and only when the page
|
|
259
|
+
// is a secure context — the legacy textarea path needs `execCommand`, which
|
|
260
|
+
// happy-dom does not implement.
|
|
261
|
+
Object.defineProperty(globalThis.window, 'isSecureContext', { value: true, configurable: true });
|
|
262
|
+
Object.defineProperty(globalThis.navigator, 'clipboard', {
|
|
263
|
+
value: {
|
|
264
|
+
writeText: async (text: string) => {
|
|
265
|
+
writes.push(text);
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
configurable: true,
|
|
269
|
+
});
|
|
270
|
+
await mount({ path: 'src/deep/file.ts', name: 'file.ts' });
|
|
271
|
+
|
|
272
|
+
await act(async () => {
|
|
273
|
+
mounted?.handleAction('copy_path');
|
|
274
|
+
});
|
|
275
|
+
await settle();
|
|
276
|
+
|
|
277
|
+
expect(writes).toEqual(['/root/src/deep/file.ts']);
|
|
278
|
+
expect(srv.requests).toEqual([]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('a folder gets the create action; a file does not offer it', async () => {
|
|
282
|
+
stubFetch(server({}));
|
|
283
|
+
await mount({ path: 'src', name: 'src' });
|
|
284
|
+
|
|
285
|
+
await act(async () => {
|
|
286
|
+
mounted?.handleAction('new_file');
|
|
287
|
+
});
|
|
288
|
+
expect(mounted?.showCreateModal).toBe(true);
|
|
289
|
+
|
|
290
|
+
await act(async () => {
|
|
291
|
+
mounted?.closeCreate();
|
|
292
|
+
});
|
|
293
|
+
expect(mounted?.showCreateModal).toBe(false);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe('useFileActions rename and delete', () => {
|
|
298
|
+
test('rename sends the new path and closes on success', async () => {
|
|
299
|
+
const srv = server({ success: true });
|
|
300
|
+
stubFetch(srv);
|
|
301
|
+
await mount({ path: 'src/old.ts', name: 'old.ts' });
|
|
302
|
+
|
|
303
|
+
await act(async () => {
|
|
304
|
+
mounted?.handleAction('rename');
|
|
305
|
+
});
|
|
306
|
+
await act(async () => {
|
|
307
|
+
mounted?.setRenameValue('src/new.ts');
|
|
308
|
+
});
|
|
309
|
+
await act(async () => {
|
|
310
|
+
mounted?.submitRename({ preventDefault() {} } as never);
|
|
311
|
+
});
|
|
312
|
+
await settle();
|
|
313
|
+
|
|
314
|
+
expect(srv.requests[0]).toEqual({ actionType: 'rename', path: 'src/old.ts', newPath: 'src/new.ts', root: '/root' });
|
|
315
|
+
expect(mounted?.showRenameModal).toBe(false);
|
|
316
|
+
expect(refreshes).toBe(1);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('delete posts the delete action for the row’s own path', async () => {
|
|
320
|
+
const srv = server({ success: true });
|
|
321
|
+
stubFetch(srv);
|
|
322
|
+
await mount({ path: 'src/gone.ts', name: 'gone.ts' });
|
|
323
|
+
|
|
324
|
+
await act(async () => {
|
|
325
|
+
mounted?.submitDelete();
|
|
326
|
+
});
|
|
327
|
+
await settle();
|
|
328
|
+
|
|
329
|
+
expect(srv.requests[0]).toEqual({ actionType: 'delete', path: 'src/gone.ts', root: '/root' });
|
|
330
|
+
expect(refreshes).toBe(1);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('a git-history response does not close the dialogs or refresh the tree', async () => {
|
|
334
|
+
// `type` marks the history payload; treating it as a mutation would refresh
|
|
335
|
+
// the tree under an open modal.
|
|
336
|
+
const srv = server({ success: true, type: 'history', data: [] } as never);
|
|
337
|
+
stubFetch(srv);
|
|
338
|
+
await mount({ path: 'src/a.ts', name: 'a.ts' });
|
|
339
|
+
|
|
340
|
+
await act(async () => {
|
|
341
|
+
mounted?.handleAction('history');
|
|
342
|
+
});
|
|
343
|
+
await settle();
|
|
344
|
+
|
|
345
|
+
expect(mounted?.showHistoryModal).toBe(true);
|
|
346
|
+
expect(refreshes).toBe(0);
|
|
347
|
+
});
|
|
348
|
+
});
|