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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ompchamber",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1",
|
|
4
4
|
"description": "Developer web console and diagnostic chamber for AI Oh-My-Pi — Elysia + Preact on Bun.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
|
package/src/cli/lib/args.js
CHANGED
|
@@ -188,7 +188,7 @@ USAGE:
|
|
|
188
188
|
COMMANDS:
|
|
189
189
|
serve Start the OMPChamber web server (default when no command is given)
|
|
190
190
|
update Update OMPChamber to the latest GitHub release
|
|
191
|
-
stop Stop
|
|
191
|
+
stop Stop the instances the CLI started (--port/--all to stop any)
|
|
192
192
|
restart Stop and start again (every CLI-started instance, or --port <port>)
|
|
193
193
|
status Show running instances (every one, or --port <port>)
|
|
194
194
|
logs Print or follow server logs (every instance, or --port <port>)
|
|
@@ -201,7 +201,7 @@ OPTIONS:
|
|
|
201
201
|
--prod Run the production build instead of the dev server
|
|
202
202
|
--foreground Run the server in the foreground (no daemon)
|
|
203
203
|
--no-daemon Alias for --foreground
|
|
204
|
-
--all
|
|
204
|
+
--all Every instance, including ones started outside the CLI
|
|
205
205
|
-c, --check Report whether a newer release exists without installing it
|
|
206
206
|
--force Reinstall even when already up to date
|
|
207
207
|
--no-restart Do not restart a running instance after updating
|
|
@@ -226,7 +226,8 @@ EXAMPLES:
|
|
|
226
226
|
ompchamber serve --prod # Serve the production build
|
|
227
227
|
ompchamber update --check # Is a newer release available?
|
|
228
228
|
ompchamber update # Update, then restart a running instance
|
|
229
|
-
ompchamber stop # Stop the
|
|
229
|
+
ompchamber stop # Stop the instances the CLI started
|
|
230
|
+
ompchamber stop --port 3000 # Stop the instance on port 3000, whoever started it
|
|
230
231
|
ompchamber restart # Restart the server
|
|
231
232
|
ompchamber status # Show whether the server is running
|
|
232
233
|
ompchamber logs -f -n 200 # Follow the last 200 log lines
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
// `ompchamber restart` — stop live instances, then serve again.
|
|
2
2
|
//
|
|
3
3
|
// Scoping matches `status` and `stop`: without `--port` every live instance is
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// considered, and `--port <port>` restricts the restart to that one. With
|
|
5
|
+
// nothing running, the command is simply `serve`.
|
|
6
6
|
//
|
|
7
7
|
// Instances the CLI did not start are left alone, exactly as `ompchamber update`
|
|
8
8
|
// leaves them: `bun run dev` and `bun run start` own their own process, so
|
|
9
|
-
// replacing it would break a loop OMPChamber does not manage. `ompchamber stop
|
|
10
|
-
// is
|
|
9
|
+
// replacing it would break a loop OMPChamber does not manage. `ompchamber stop
|
|
10
|
+
// --port <port>` (or `--all`) is the deliberate way to end one.
|
|
11
11
|
|
|
12
12
|
import { findLiveInstance, listLiveInstances, stopInstance } from '@/cli/lib/runtime.js';
|
|
13
13
|
import { STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
|
|
14
14
|
import { log, isJson, isQuiet } from '@/cli/lib/output.js';
|
|
15
15
|
import { run as runServe } from '@/cli/lib/commands/serve.js';
|
|
16
|
-
import {
|
|
16
|
+
import { skipRestartNote } from '@/server/lib/lifecycle/restart';
|
|
17
|
+
import { isCliManaged } from '@/server/lib/lifecycle/launch-mode';
|
|
17
18
|
|
|
18
19
|
function explicitPort(options) {
|
|
19
20
|
const raw = options?.port;
|
|
@@ -34,7 +35,7 @@ export async function run(options, ctx) {
|
|
|
34
35
|
if (targets.length === 0) return runServe(options, ctx);
|
|
35
36
|
|
|
36
37
|
for (const live of targets) {
|
|
37
|
-
if (!
|
|
38
|
+
if (!isCliManaged(live.launchMode)) {
|
|
38
39
|
if (!json && !quiet) log(`Skipping OMPChamber on port ${live.port}: ${skipRestartNote(live.launchMode)}.`);
|
|
39
40
|
continue;
|
|
40
41
|
}
|
|
@@ -1,22 +1,19 @@
|
|
|
1
|
-
// `ompchamber stop` — stop
|
|
1
|
+
// `ompchamber stop` — stop OMPChamber instances.
|
|
2
|
+
//
|
|
3
|
+
// Scope follows OWNERSHIP, the same rule `restart` and `update` apply. By
|
|
4
|
+
// default only the instances the CLI started are stopped, because a server
|
|
5
|
+
// running from source is supervised by a launcher the CLI does not own and
|
|
6
|
+
// that launcher exits with the child it spawned: `bun run dev` is a process
|
|
7
|
+
// whose whole job is to mirror the script under it, so signalling the server
|
|
8
|
+
// takes the user's dev loop down with it. `--port <port>` and `--all` are the
|
|
9
|
+
// explicit forms that do end one — naming an instance is a deliberate call,
|
|
10
|
+
// not a default.
|
|
2
11
|
|
|
3
12
|
import { listLiveInstances, findLiveInstance, stopInstance } from '@/cli/lib/runtime.js';
|
|
4
13
|
import { STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
|
|
5
|
-
import { ok, warn, printJson, isJson } from '@/cli/lib/output.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const raw = options?.port;
|
|
9
|
-
if (raw !== null && raw !== undefined) {
|
|
10
|
-
const parsed = Number(raw);
|
|
11
|
-
if (Number.isFinite(parsed)) return parsed;
|
|
12
|
-
}
|
|
13
|
-
const env = Bun.env.OMPCHAMBER_PORT;
|
|
14
|
-
if (typeof env === 'string' && env.trim().length > 0) {
|
|
15
|
-
const parsed = Number(env.trim());
|
|
16
|
-
if (Number.isFinite(parsed)) return parsed;
|
|
17
|
-
}
|
|
18
|
-
return 3000;
|
|
19
|
-
}
|
|
14
|
+
import { ok, warn, log, printJson, isJson, isQuiet } from '@/cli/lib/output.js';
|
|
15
|
+
import { unmanagedReason } from '@/server/lib/lifecycle/launch-mode';
|
|
16
|
+
import { partitionStopTargets } from '@/cli/lib/stop-scope.js';
|
|
20
17
|
|
|
21
18
|
function explicitPort(options) {
|
|
22
19
|
const raw = options?.port;
|
|
@@ -25,30 +22,40 @@ function explicitPort(options) {
|
|
|
25
22
|
return Number.isFinite(parsed) ? parsed : null;
|
|
26
23
|
}
|
|
27
24
|
|
|
25
|
+
/** How to end an instance the default scope leaves alone. */
|
|
26
|
+
function stopHint(port) {
|
|
27
|
+
return `stop it with \`ompchamber stop --port ${port}\` or \`ompchamber stop --all\``;
|
|
28
|
+
}
|
|
29
|
+
|
|
28
30
|
export async function run(options) {
|
|
29
31
|
const json = isJson();
|
|
32
|
+
const quiet = isQuiet();
|
|
30
33
|
const requested = explicitPort(options);
|
|
34
|
+
// `--all` is the explicit spelling of "every instance, whoever started it".
|
|
35
|
+
const explicitScope = requested !== null || Boolean(options?.all);
|
|
36
|
+
|
|
37
|
+
const live = requested !== null
|
|
38
|
+
? [await findLiveInstance(requested)].filter(Boolean)
|
|
39
|
+
: await listLiveInstances();
|
|
40
|
+
const { targets, skipped } = partitionStopTargets(live, explicitScope);
|
|
41
|
+
const skippedReport = skipped.map((entry) => ({ port: entry.port, pid: entry.pid, launchMode: entry.launchMode }));
|
|
31
42
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
let targets;
|
|
37
|
-
if (requested !== null) {
|
|
38
|
-
const live = await findLiveInstance(requested);
|
|
39
|
-
targets = live ? [live] : [];
|
|
40
|
-
} else {
|
|
41
|
-
targets = await listLiveInstances();
|
|
43
|
+
if (!json && !quiet) {
|
|
44
|
+
for (const entry of skipped) {
|
|
45
|
+
log(`Skipping OMPChamber on port ${entry.port} (pid ${entry.pid}): it ${unmanagedReason(entry.launchMode)} — ${stopHint(entry.port)}.`);
|
|
46
|
+
}
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
if (targets.length === 0) {
|
|
45
50
|
if (json) {
|
|
46
|
-
printJson({ stopped: [] });
|
|
51
|
+
printJson({ stopped: [], skipped: skippedReport });
|
|
47
52
|
return;
|
|
48
53
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
if (skipped.length === 0) {
|
|
55
|
+
warn(requested === null
|
|
56
|
+
? 'No running OMPChamber instances found.'
|
|
57
|
+
: `No running OMPChamber instance on port ${requested}.`);
|
|
58
|
+
}
|
|
52
59
|
return;
|
|
53
60
|
}
|
|
54
61
|
|
|
@@ -64,5 +71,5 @@ export async function run(options) {
|
|
|
64
71
|
}
|
|
65
72
|
}
|
|
66
73
|
|
|
67
|
-
if (json) printJson({ stopped });
|
|
74
|
+
if (json) printJson({ stopped, skipped: skippedReport });
|
|
68
75
|
}
|
|
@@ -10,7 +10,8 @@ import { color, configure, error, log, ok, warn, printJson, isJson, isQuiet } fr
|
|
|
10
10
|
import { listLiveInstances, findLiveInstance, stopInstance } from '@/cli/lib/runtime.js';
|
|
11
11
|
import { STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
|
|
12
12
|
import { run as runServe } from '@/cli/lib/commands/serve.js';
|
|
13
|
-
import {
|
|
13
|
+
import { skipRestartNote } from '@/server/lib/lifecycle/restart';
|
|
14
|
+
import { isCliManaged } from '@/server/lib/lifecycle/launch-mode';
|
|
14
15
|
import { resolveInstallContext, resolveOmpChamberVersion, updateOmpChamber } from '@/server/lib/updates/install';
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -30,7 +31,7 @@ async function restartInstances(options, ctx) {
|
|
|
30
31
|
const restarted = [];
|
|
31
32
|
const skipped = [];
|
|
32
33
|
for (const entry of await liveInstances(options)) {
|
|
33
|
-
if (!
|
|
34
|
+
if (!isCliManaged(entry.launchMode)) {
|
|
34
35
|
skipped.push({ port: entry.port, pid: entry.pid, launchMode: entry.launchMode });
|
|
35
36
|
log(`Skipping OMPChamber on port ${entry.port}: ${skipRestartNote(entry.launchMode)}.`);
|
|
36
37
|
continue;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Ownership scope for `ompchamber stop`.
|
|
2
|
+
//
|
|
3
|
+
// Kept separate from the command so the rule is testable without a live
|
|
4
|
+
// server: the default scope is the instances the CLI started, and only an
|
|
5
|
+
// explicit `--port <port>` / `--all` widens it to the ones it did not. That
|
|
6
|
+
// distinction is the whole reason this module exists — a server running from
|
|
7
|
+
// source sits under a launcher that exits with its child, so stopping it by
|
|
8
|
+
// default takes a `bun run dev` loop down with it.
|
|
9
|
+
|
|
10
|
+
import { isCliManaged } from '@/server/lib/lifecycle/launch-mode';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Split the live instances into the ones `stop` acts on and the ones it leaves
|
|
14
|
+
* alone. `explicit` is true when the user named a port or passed `--all`:
|
|
15
|
+
* naming an instance is a deliberate call, so ownership no longer gates it.
|
|
16
|
+
*/
|
|
17
|
+
export function partitionStopTargets(instances, explicit) {
|
|
18
|
+
if (explicit) return { targets: instances, skipped: [] };
|
|
19
|
+
return {
|
|
20
|
+
targets: instances.filter((entry) => isCliManaged(entry.launchMode)),
|
|
21
|
+
skipped: instances.filter((entry) => !isCliManaged(entry.launchMode)),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import { partitionStopTargets } from '@/cli/lib/stop-scope.js';
|
|
9
|
+
|
|
10
|
+
const daemon = { port: 3000, pid: 11, launchMode: 'daemon' };
|
|
11
|
+
const foreground = { port: 3001, pid: 12, launchMode: 'foreground' };
|
|
12
|
+
const dev = { port: 3002, pid: 13, launchMode: 'direct' };
|
|
13
|
+
const unknown = { port: 3003, pid: 14, launchMode: 'unknown' };
|
|
14
|
+
|
|
15
|
+
describe('partitionStopTargets', () => {
|
|
16
|
+
// The default scope is ownership, not "everything that answers /api/health".
|
|
17
|
+
// `bun run dev` is a supervisor whose only job is to mirror the script under
|
|
18
|
+
// it, so stopping that server exits the user's dev loop too — the bug this
|
|
19
|
+
// rule exists to prevent.
|
|
20
|
+
test('stops only the instances the CLI started when the scope is implicit', () => {
|
|
21
|
+
const { targets, skipped } = partitionStopTargets([daemon, dev, foreground, unknown], false);
|
|
22
|
+
expect(targets).toEqual([daemon, foreground]);
|
|
23
|
+
expect(skipped).toEqual([dev, unknown]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Naming an instance — a port or `--all` — is a deliberate call, so the
|
|
27
|
+
// ownership rule no longer gates it.
|
|
28
|
+
test('widens to every instance when the scope is explicit', () => {
|
|
29
|
+
const { targets, skipped } = partitionStopTargets([daemon, dev, unknown], true);
|
|
30
|
+
expect(targets).toEqual([daemon, dev, unknown]);
|
|
31
|
+
expect(skipped).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('an unreadable launch mode is never stopped implicitly', () => {
|
|
35
|
+
// A record whose launchMode could not be read must not authorise a kill:
|
|
36
|
+
// the safe reading is "not ours".
|
|
37
|
+
const { targets, skipped } = partitionStopTargets([{ port: 4000, pid: 99, launchMode: undefined }], false);
|
|
38
|
+
expect(targets).toEqual([]);
|
|
39
|
+
expect(skipped.length).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The editor's keydown dispatcher, exercised with a fake textarea.
|
|
8
|
+
*
|
|
9
|
+
* These assertions are the routing contract: which layer owns a key, and what
|
|
10
|
+
* happens to the event when nobody does. The failures they guard against are
|
|
11
|
+
* all silent — a chord that reaches the browser's own find bar, an Escape that
|
|
12
|
+
* throws away a multi-selection instead of dropping it, a Tab that escapes the
|
|
13
|
+
* editor mid-line.
|
|
14
|
+
*
|
|
15
|
+
* The platform is PINNED rather than detected. The dispatcher resolves chords
|
|
16
|
+
* through `isMacPlatform()`, so a test written with ⌘ chords passes on a
|
|
17
|
+
* developer's Mac and fails on the Linux CI runner — which is exactly what
|
|
18
|
+
* happened. `usePlatform()` below makes each case state the platform it means.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, expect, test } from 'bun:test';
|
|
22
|
+
|
|
23
|
+
import { handleEditorKeydown, runEditorCommand, type EditorKeydownDeps } from '@/client/components/common/code-editor/handler';
|
|
24
|
+
import { createHistory, pushRecord } from '@/shared/lib/code/editor/history';
|
|
25
|
+
import type { EditorHistory } from '@/client/hooks/editor/use-editor-history';
|
|
26
|
+
import type { OccurrencesApi } from '@/client/hooks/editor/use-occurrences';
|
|
27
|
+
import type { EditorCommand } from '@/shared/lib/code/editor/keymap';
|
|
28
|
+
import type { TextRange } from '@/shared/lib/code/editor/commands';
|
|
29
|
+
|
|
30
|
+
/** A textarea stand-in: the dispatcher only reads value/selection and calls blur. */
|
|
31
|
+
function fakeInput(value: string, start: number, end = start) {
|
|
32
|
+
return {
|
|
33
|
+
value,
|
|
34
|
+
selectionStart: start,
|
|
35
|
+
selectionEnd: end,
|
|
36
|
+
blurred: false,
|
|
37
|
+
blur() {
|
|
38
|
+
this.blurred = true;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Harness {
|
|
44
|
+
deps: EditorKeydownDeps;
|
|
45
|
+
committed: Array<{ value: string; selectionStart: number; selectionEnd: number }>;
|
|
46
|
+
steps: string[];
|
|
47
|
+
delegated: EditorCommand[];
|
|
48
|
+
cleared: number;
|
|
49
|
+
selected: Array<[number, number]>;
|
|
50
|
+
ranges: TextRange[];
|
|
51
|
+
captureToggles: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function harness(options: { value: string; start?: number; end?: number; ranges?: TextRange[]; language?: string; delegate?: (c: EditorCommand) => boolean } = { value: '' }): Harness {
|
|
55
|
+
const committed: Harness['committed'] = [];
|
|
56
|
+
const steps: string[] = [];
|
|
57
|
+
const delegated: EditorCommand[] = [];
|
|
58
|
+
const selected: Array<[number, number]> = [];
|
|
59
|
+
const state = { ranges: options.ranges ? [...options.ranges] : [] as TextRange[] };
|
|
60
|
+
let cleared = 0;
|
|
61
|
+
let captureToggles = 0;
|
|
62
|
+
|
|
63
|
+
const history: EditorHistory = {
|
|
64
|
+
history: createHistory(),
|
|
65
|
+
buffer: options.value,
|
|
66
|
+
commit: (edit) => committed.push(edit),
|
|
67
|
+
commitEdit: (edit) => committed.push(edit),
|
|
68
|
+
step: (direction) => steps.push(direction),
|
|
69
|
+
markApplied: () => {},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const multi: OccurrencesApi = {
|
|
73
|
+
get ranges() {
|
|
74
|
+
return state.ranges;
|
|
75
|
+
},
|
|
76
|
+
set: (ranges) => {
|
|
77
|
+
state.ranges = [...ranges];
|
|
78
|
+
},
|
|
79
|
+
clear: () => {
|
|
80
|
+
cleared++;
|
|
81
|
+
state.ranges = [];
|
|
82
|
+
},
|
|
83
|
+
selectNext: (input, select) => select(input.selectionStart, input.selectionEnd + 1),
|
|
84
|
+
selectAll: (input, select) => select(0, input.value.length),
|
|
85
|
+
insertCursorBelow: () => {},
|
|
86
|
+
replicate: () => null,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const input = fakeInput(options.value, options.start ?? 0, options.end ?? options.start ?? 0);
|
|
90
|
+
const deps: EditorKeydownDeps = {
|
|
91
|
+
input: input as unknown as HTMLTextAreaElement,
|
|
92
|
+
language: options.language ?? 'typescript',
|
|
93
|
+
captureTab: true,
|
|
94
|
+
toggleCaptureTab: () => captureToggles++,
|
|
95
|
+
history,
|
|
96
|
+
multi,
|
|
97
|
+
select: (start, end) => selected.push([start, end]),
|
|
98
|
+
delegate: (command) => {
|
|
99
|
+
delegated.push(command);
|
|
100
|
+
options.delegate?.(command);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
deps,
|
|
106
|
+
committed,
|
|
107
|
+
steps,
|
|
108
|
+
delegated,
|
|
109
|
+
selected,
|
|
110
|
+
get cleared() {
|
|
111
|
+
return cleared;
|
|
112
|
+
},
|
|
113
|
+
get captureToggles() {
|
|
114
|
+
return captureToggles;
|
|
115
|
+
},
|
|
116
|
+
get ranges() {
|
|
117
|
+
return state.ranges;
|
|
118
|
+
},
|
|
119
|
+
} as Harness;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Run `body` with `navigator.platform` reporting `platform`. */
|
|
123
|
+
function usePlatform<T>(platform: string, body: () => T): T {
|
|
124
|
+
const globals = globalThis as Record<string, unknown>;
|
|
125
|
+
const previous = globals.navigator;
|
|
126
|
+
globals.navigator = { platform };
|
|
127
|
+
try {
|
|
128
|
+
return body();
|
|
129
|
+
} finally {
|
|
130
|
+
if (previous === undefined) delete globals.navigator;
|
|
131
|
+
else globals.navigator = previous;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A keydown event with only the fields the dispatcher reads. */
|
|
136
|
+
function press(key: string, code: string, modifiers: Partial<{ metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean }> = {}) {
|
|
137
|
+
const prevented = { value: false };
|
|
138
|
+
const event = {
|
|
139
|
+
key,
|
|
140
|
+
code,
|
|
141
|
+
metaKey: false,
|
|
142
|
+
ctrlKey: false,
|
|
143
|
+
altKey: false,
|
|
144
|
+
shiftKey: false,
|
|
145
|
+
...modifiers,
|
|
146
|
+
preventDefault: () => {
|
|
147
|
+
prevented.value = true;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
return { event: event as never, prevented };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe('handleEditorKeydown', () => {
|
|
154
|
+
test('Escape drops a live multi-selection before it leaves the editor', () => {
|
|
155
|
+
const first = harness({ value: 'abc', ranges: [{ start: 1, end: 2 }] });
|
|
156
|
+
const one = press('Escape', 'Escape');
|
|
157
|
+
handleEditorKeydown(one.event, first.deps);
|
|
158
|
+
|
|
159
|
+
expect(first.cleared).toBe(1);
|
|
160
|
+
expect(one.prevented.value).toBe(true);
|
|
161
|
+
|
|
162
|
+
// With no extra ranges left, Escape is a plain blur.
|
|
163
|
+
const input = first.deps.input as unknown as { blurred: boolean };
|
|
164
|
+
const two = press('Escape', 'Escape');
|
|
165
|
+
handleEditorKeydown(two.event, first.deps);
|
|
166
|
+
|
|
167
|
+
expect(input.blurred).toBe(true);
|
|
168
|
+
expect(two.prevented.value).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('Tab indents through the history, not through a raw write', () => {
|
|
172
|
+
const h = harness({ value: 'one', start: 0 });
|
|
173
|
+
const { event, prevented } = press('Tab', 'Tab');
|
|
174
|
+
handleEditorKeydown(event, h.deps);
|
|
175
|
+
|
|
176
|
+
expect(prevented.value).toBe(true);
|
|
177
|
+
expect(h.committed[0].value).toBe(' one');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('undo and redo step the history and drop the extra ranges', () => {
|
|
181
|
+
const h = harness({ value: 'abc', ranges: [{ start: 1, end: 2 }] });
|
|
182
|
+
usePlatform('MacIntel', () => handleEditorKeydown(press('z', 'KeyZ', { metaKey: true }).event, h.deps));
|
|
183
|
+
|
|
184
|
+
expect(h.steps).toEqual(['undo']);
|
|
185
|
+
expect(h.cleared).toBe(1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('a line command commits and clears the ranges it just invalidated', () => {
|
|
189
|
+
const h = harness({ value: 'one\ntwo', start: 0, end: 3, ranges: [{ start: 8, end: 9 }] });
|
|
190
|
+
handleEditorKeydown(press('ArrowDown', 'ArrowDown', { altKey: true }).event, h.deps);
|
|
191
|
+
|
|
192
|
+
expect(h.committed[0].value).toBe('two\none');
|
|
193
|
+
expect(h.cleared).toBe(1);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('⌘D routes to the occurrence grower with the editor’s own select', () => {
|
|
197
|
+
const h = harness({ value: 'alpha', start: 0, end: 5 });
|
|
198
|
+
usePlatform('MacIntel', () => handleEditorKeydown(press('d', 'KeyD', { metaKey: true }).event, h.deps));
|
|
199
|
+
|
|
200
|
+
expect(h.selected).toEqual([[0, 6]]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('a find-bar chord is delegated and consumed', () => {
|
|
204
|
+
// The bar may not be open: nothing handles ⌘F, but the browser's own find
|
|
205
|
+
// bar must not open over the editor either — so the chord is consumed
|
|
206
|
+
// whatever the panel does with it.
|
|
207
|
+
const h = harness({ value: 'abc' });
|
|
208
|
+
const { event, prevented } = press('f', 'KeyF', { metaKey: true });
|
|
209
|
+
usePlatform('MacIntel', () => handleEditorKeydown(event, h.deps));
|
|
210
|
+
|
|
211
|
+
expect(h.delegated).toEqual(['find']);
|
|
212
|
+
expect(prevented.value).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('a palette-style call runs a text command with no event at all', () => {
|
|
216
|
+
const h = harness({ value: 'one\ntwo', start: 0, end: 3 });
|
|
217
|
+
runEditorCommand('copyLineDown', h.deps);
|
|
218
|
+
|
|
219
|
+
expect(h.committed[0].value).toBe('one\none\ntwo');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('an unbound chord is left to the browser', () => {
|
|
223
|
+
const h = harness({ value: 'abc' });
|
|
224
|
+
const { event, prevented } = press('a', 'KeyA', { metaKey: true });
|
|
225
|
+
handleEditorKeydown(event, h.deps);
|
|
226
|
+
|
|
227
|
+
expect(h.delegated).toEqual([]);
|
|
228
|
+
expect(prevented.value).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('a chord built on Enter reaches the keymap instead of the plain-Enter branch', () => {
|
|
232
|
+
// ⌘⏎ is `insertLineBelow`; the plain-Enter auto-indent branch used to catch
|
|
233
|
+
// it first and do nothing on an unindented line, making the command dead.
|
|
234
|
+
const h = harness({ value: 'const a = 1;\nconst b = 2;', start: 20 });
|
|
235
|
+
usePlatform('MacIntel', () => handleEditorKeydown(press('Enter', 'Enter', { metaKey: true }).event, h.deps));
|
|
236
|
+
|
|
237
|
+
expect(h.committed[0].value).toBe('const a = 1;\nconst b = 2;\n');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('plain Enter still auto-indents', () => {
|
|
241
|
+
const h = harness({ value: ' one', start: 5 });
|
|
242
|
+
handleEditorKeydown(press('Enter', 'Enter').event, h.deps);
|
|
243
|
+
|
|
244
|
+
expect(h.committed[0].value).toBe(' one\n ');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('⌘[ outdents the line the caret is on, with nothing selected', () => {
|
|
248
|
+
const h = harness({ value: ' one', start: 2 });
|
|
249
|
+
usePlatform('MacIntel', () => handleEditorKeydown(press('[', 'BracketLeft', { metaKey: true }).event, h.deps));
|
|
250
|
+
|
|
251
|
+
expect(h.committed[0].value).toBe('one');
|
|
252
|
+
expect(h.committed[0].selectionStart).toBe(0);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('Ctrl+M still toggles Tab capture', () => {
|
|
256
|
+
const h = harness({ value: 'abc' });
|
|
257
|
+
// On macOS the chord needs Shift (Ctrl+M alone is Return in the terminal's
|
|
258
|
+
// own vocabulary), which is what the handler encodes. Both platforms are
|
|
259
|
+
// exercised explicitly rather than detected.
|
|
260
|
+
usePlatform('Win32', () => handleEditorKeydown(press('m', 'KeyM', { ctrlKey: true }).event, h.deps));
|
|
261
|
+
expect(h.captureToggles).toBe(1);
|
|
262
|
+
|
|
263
|
+
usePlatform('MacIntel', () => handleEditorKeydown(press('m', 'KeyM', { ctrlKey: true, shiftKey: true }).event, h.deps));
|
|
264
|
+
expect(h.captureToggles).toBe(2);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe('editor keymap ownership', () => {
|
|
269
|
+
test('the editor’s own history is seeded, not the browser’s', () => {
|
|
270
|
+
const history = createHistory();
|
|
271
|
+
pushRecord(history, { value: 'a', selectionStart: 1, selectionEnd: 1, timestamp: 1 });
|
|
272
|
+
expect(history.stack.length).toBe(1);
|
|
273
|
+
});
|
|
274
|
+
});
|