ompchamber 0.5.0 → 0.6.0

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.
Files changed (76) hide show
  1. package/README.md +46 -1
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/static/css/{index.5291f58223.css → index.51f68210a5.css} +1 -1
  4. package/dist/client/static/js/async/{3y.ededeebde8.js → 3y.4c2596f64c.js} +3 -3
  5. package/dist/client/static/js/async/73.308dff4da8.js +1 -0
  6. package/dist/client/static/js/async/{ty.3d93cf5f5f.js → 7g.a7814350c3.js} +1 -1
  7. package/dist/client/static/js/async/a.bce4542392.js +1 -0
  8. package/dist/client/static/js/async/g.8515cbc6f7.js +2 -0
  9. package/dist/client/static/js/async/hn.b645041e00.js +1 -0
  10. package/dist/client/static/js/async/{jg.69a4beade8.js → jg.d86ebd4013.js} +1 -1
  11. package/dist/client/static/js/async/l.12dd34e1be.js +1 -0
  12. package/dist/client/static/js/async/ls.9d9a01026c.js +104 -0
  13. package/dist/client/static/js/async/mw.ca1512563c.js +10 -0
  14. package/dist/client/static/js/async/nk.fc7e58325e.js +1 -0
  15. package/dist/client/static/js/async/{q.7fb4d2f374.js → q.542c184fec.js} +3 -3
  16. package/dist/client/static/js/async/t.cda4891526.js +1 -0
  17. package/dist/client/static/js/async/tr.e878658140.js +309 -0
  18. package/dist/client/static/js/async/uf.24ca3d49c3.js +2 -0
  19. package/dist/client/static/js/{index.0ac93ee878.js → index.47d3b04d5f.js} +7 -7
  20. package/dist/client/static/js/{uy.5f791144ce.js → uy.2e8929ed0c.js} +2 -2
  21. package/package.json +13 -5
  22. package/src/cli/lib/args.js +6 -6
  23. package/src/cli/lib/commands/logs.js +76 -34
  24. package/src/cli/lib/commands/restart.js +30 -9
  25. package/src/cli/lib/commands/serve.js +12 -4
  26. package/src/cli/lib/commands/status.js +40 -18
  27. package/src/cli/lib/commands/stop.js +12 -7
  28. package/src/cli/lib/commands/update.js +2 -5
  29. package/src/cli/lib/paths.js +8 -28
  30. package/src/cli/lib/runtime.js +118 -108
  31. package/src/client/components/common/code-editor/index.tsx +5 -1
  32. package/src/client/components/common/code-surface/index.tsx +49 -1
  33. package/src/client/components/common/code-surface/measure.ts +99 -0
  34. package/src/client/components/layout/RightActivityBar.tsx +42 -69
  35. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +5 -6
  36. package/src/client/components/layout/desktop-layout/index.tsx +11 -9
  37. package/src/client/components/layout/desktop-layout/resizer/index.tsx +22 -29
  38. package/src/client/components/layout/desktop-layout/resizer/utils.test.ts +33 -0
  39. package/src/client/components/layout/desktop-layout/resizer/utils.ts +34 -0
  40. package/src/client/components/mobile/RightSidebar.tsx +42 -119
  41. package/src/client/components/mobile/mobile-main-view/Header.tsx +17 -1
  42. package/src/client/components/workspace/editor/index.tsx +4 -1
  43. package/src/client/hooks/editor/use-file-editor.test.ts +158 -0
  44. package/src/client/hooks/editor/use-file-editor.ts +35 -19
  45. package/src/server/index.ts +51 -1
  46. package/src/server/lib/lifecycle/identity.ts +108 -0
  47. package/src/server/lib/lifecycle/instance.ts +102 -0
  48. package/src/server/lib/lifecycle/occupant.ts +101 -0
  49. package/src/server/lib/lifecycle/paths.ts +50 -0
  50. package/src/server/lib/lifecycle/port-guard.ts +170 -0
  51. package/src/server/lib/lifecycle/probe.ts +121 -0
  52. package/src/server/plugins/static.test.ts +63 -0
  53. package/src/server/plugins/static.ts +28 -4
  54. package/src/server/routes/health/probe.ts +8 -0
  55. package/src/shared/lib/markdown/mermaid.ts +8 -0
  56. package/src/shared/lib/workspace/panel-widths.test.ts +5 -5
  57. package/src/shared/lib/workspace/panel-widths.ts +23 -10
  58. package/src/shared/lib/workspace/right-panels.ts +10 -4
  59. package/dist/client/static/js/async/73.50280ce58d.js +0 -1
  60. package/dist/client/static/js/async/a.e05da304c8.js +0 -10
  61. package/dist/client/static/js/async/ar.1e14da5588.js +0 -1
  62. package/dist/client/static/js/async/g.7a4bbd4798.js +0 -2
  63. package/dist/client/static/js/async/hn.7568bdb58b.js +0 -1
  64. package/dist/client/static/js/async/l.102a97fce6.js +0 -104
  65. package/dist/client/static/js/async/l6.c56640c205.js +0 -1
  66. package/dist/client/static/js/async/nk.6de011832e.js +0 -1
  67. package/dist/client/static/js/async/t.cb3fcf8615.js +0 -1
  68. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  69. package/dist/client/static/js/async/vn.e98e2f3b61.js +0 -309
  70. /package/dist/client/static/js/async/{3y.ededeebde8.js.LICENSE.txt → 3y.4c2596f64c.js.LICENSE.txt} +0 -0
  71. /package/dist/client/static/js/async/{g.7a4bbd4798.js.LICENSE.txt → g.8515cbc6f7.js.LICENSE.txt} +0 -0
  72. /package/dist/client/static/js/async/{a.e05da304c8.js.LICENSE.txt → mw.ca1512563c.js.LICENSE.txt} +0 -0
  73. /package/dist/client/static/js/async/{q.7fb4d2f374.js.LICENSE.txt → q.542c184fec.js.LICENSE.txt} +0 -0
  74. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → uf.24ca3d49c3.js.LICENSE.txt} +0 -0
  75. /package/dist/client/static/js/{index.0ac93ee878.js.LICENSE.txt → index.47d3b04d5f.js.LICENSE.txt} +0 -0
  76. /package/dist/client/static/js/{uy.5f791144ce.js.LICENSE.txt → uy.2e8929ed0c.js.LICENSE.txt} +0 -0
@@ -1,111 +1,95 @@
1
- // Runtime helpers for the OMPChamber CLI: registry persistence, process
2
- // liveness, server entry resolution, health probing and detached spawning.
3
- // Bun-native file APIs are used wherever Bun ships them; node:fs remains for
4
- // sync directory/metadata ops and 0o600 file creation (Bun.write ignores mode).
1
+ // Runtime helpers for the OMPChamber CLI: instance discovery, process identity,
2
+ // server entry resolution, health probing and detached spawning.
3
+ //
4
+ // The instance record is written by the server that owns the port (see
5
+ // src/server/lib/lifecycle/instance.ts), not by the CLI that spawned it, so a
6
+ // server started by `bun run dev` or by `--foreground` is as discoverable as a
7
+ // detached one. Discovery combines that record with a live `/api/health` probe:
8
+ // the probe covers servers started outside the CLI, the record covers servers
9
+ // that are alive but deaf (starting up, hung).
5
10
 
6
11
  import fs from 'node:fs';
7
12
 
8
- import { getLogFilePath, getRegistryPath, ensureDataDirs, getRunDir } from '@/cli/lib/paths.js';
13
+ import { ensureDataDirs, getLogFilePath } from '@/cli/lib/paths.js';
9
14
  import { killChildTree, STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
15
+ import { getProcessState, isProcessAlive } from '@/server/lib/lifecycle/identity';
16
+ import { listInstanceRecords, readInstanceRecord, removeInstanceRecord } from '@/server/lib/lifecycle/instance';
17
+ import { fetchHealth, probeHost } from '@/server/lib/lifecycle/probe';
10
18
  import { joinPath, homeDir } from '@/cli/lib/path-utils.js';
11
19
 
12
20
  const HEALTH_INTERVAL_MS = 500;
13
21
  const HEALTH_TIMEOUT_MS = 30_000;
14
22
 
15
23
  /**
16
- * Read and parse the registry entry for `port`. Returns null on any error.
24
+ * CLI shape of a live instance, so records and probed servers print the same.
17
25
  */
18
- export async function readRegistry(port) {
19
- try {
20
- const parsed = await Bun.file(getRegistryPath(port)).json();
21
- return parsed && typeof parsed === 'object' ? parsed : null;
22
- } catch {
23
- return null;
24
- }
25
- }
26
-
27
- /**
28
- * Read every `<port>.json` registry entry in the run directory.
29
- */
30
- export async function listRegistries() {
31
- try {
32
- const dir = getRunDir();
33
- const entries = [];
34
- for (const file of await fs.promises.readdir(dir)) {
35
- if (!/^\d+\.json$/.test(file)) continue;
36
- try {
37
- const parsed = await Bun.file(joinPath(dir, file)).json();
38
- if (parsed && typeof parsed === 'object') entries.push(parsed);
39
- } catch {
40
- // Skip unreadable or corrupt registry files.
41
- }
42
- }
43
- return entries;
44
- } catch {
45
- return [];
46
- }
47
- }
48
-
49
- /**
50
- * Atomically write a registry entry (tmp file + rename) with owner-only perms.
51
- */
52
- export function writeRegistry(entry) {
53
- if (!entry || entry.port === undefined || entry.port === null) {
54
- throw new Error('writeRegistry requires an entry with a port');
55
- }
56
- ensureDataDirs();
57
- const target = getRegistryPath(entry.port);
58
- const tmp = `${target}.${process.pid}.tmp`;
59
- fs.writeFileSync(tmp, JSON.stringify(entry, null, 2), { mode: 0o600 });
60
- fs.renameSync(tmp, target);
61
- return target;
26
+ function toLiveInstance({ pid, port, host, mode, launchMode, startedAt, source }) {
27
+ const resolvedHost = host || 'localhost';
28
+ return {
29
+ pid,
30
+ port,
31
+ host: resolvedHost,
32
+ mode: mode ?? 'unknown',
33
+ launchMode: launchMode ?? 'unknown',
34
+ startedAt,
35
+ source: source ?? 'registry',
36
+ logFile: getLogFilePath(port),
37
+ url: `http://${probeHost(resolvedHost)}:${port}`,
38
+ };
62
39
  }
63
40
 
64
41
  /**
65
- * Remove a registry entry. Never throws.
42
+ * A recorded PID counts as the instance only while it is alive *and* still
43
+ * identifiable: a recycled PID must not resurrect a dead instance. `unknown`
44
+ * (identity unreadable on this platform) is accepted so Windows keeps working.
66
45
  */
67
- export async function removeRegistry(port) {
68
- try {
69
- await fs.promises.rm(getRegistryPath(port), { force: true });
70
- } catch {
71
- // Nothing to do: the registry is best-effort state.
72
- }
46
+ function isRecordLive(record) {
47
+ if (!record) return false;
48
+ return isProcessAlive(record.pid) && getProcessState(record.pid) !== 'mismatched';
73
49
  }
74
50
 
75
51
  /**
76
- * Liveness-only check for a PID. Never throws.
52
+ * Every recorded instance whose process is still alive. Records left behind by
53
+ * a killed server (SIGKILL skips its cleanup handler) are pruned here, so a
54
+ * recycled PID can never be reported as a running instance.
77
55
  */
78
- export function isProcessAlive(pid) {
79
- try {
80
- process.kill(pid, 0);
81
- return true;
82
- } catch {
83
- return false;
56
+ export async function listLiveInstances() {
57
+ const live = [];
58
+ for (const record of listInstanceRecords()) {
59
+ if (isRecordLive(record)) live.push(toLiveInstance(record));
60
+ else removeInstanceRecord(record.port);
84
61
  }
62
+ // Directory order is filesystem-dependent; a port-ordered report is stable
63
+ // across runs and keeps multi-instance output readable.
64
+ live.sort((a, b) => a.port - b.port);
65
+ return live;
85
66
  }
86
67
 
87
68
  /**
88
- * Registry entry whose recorded PID is alive. With no port, returns the first
89
- * live instance; otherwise null.
69
+ * The live instance on `port`, or the first live instance when no port is
70
+ * given. Falls back to a health probe for servers that never wrote a record.
90
71
  */
91
72
  export async function findLiveInstance(port) {
92
73
  if (port === null || port === undefined) {
93
- const all = await listRegistries();
94
- return all.find((entry) => entry && isProcessAlive(Number(entry.pid))) ?? null;
74
+ const [live] = await listLiveInstances();
75
+ return live ?? null;
95
76
  }
96
- const entry = await readRegistry(port);
97
- if (entry && isProcessAlive(Number(entry.pid))) return entry;
98
- return null;
99
- }
100
77
 
101
- /**
102
- * Map a bind host to an address that is actually reachable for probing.
103
- */
104
- export function probeHost(host) {
105
- if (host === '0.0.0.0' || host === '::' || host === '' || host === null || host === undefined) {
106
- return '127.0.0.1';
107
- }
108
- return host;
78
+ const record = readInstanceRecord(port);
79
+ if (isRecordLive(record)) return toLiveInstance(record);
80
+ if (record) removeInstanceRecord(port);
81
+
82
+ const health = await fetchHealth(port, record?.host);
83
+ if (!health || typeof health.pid !== 'number' || !isProcessAlive(health.pid)) return null;
84
+ return toLiveInstance({
85
+ pid: health.pid,
86
+ port,
87
+ host: health.host ?? record?.host,
88
+ mode: health.mode,
89
+ launchMode: health.launchMode ?? record?.launchMode,
90
+ startedAt: health.startedAt,
91
+ source: 'probe',
92
+ });
109
93
  }
110
94
 
111
95
  function isBun(candidate) {
@@ -147,9 +131,12 @@ export function resolveBunBin() {
147
131
  *
148
132
  * Bun executes the TypeScript entry directly in both modes, so dev and prod
149
133
  * differ only by NODE_ENV — the client bundle in `dist/client` is required by
150
- * the SSR shell either way.
134
+ * the SSR shell either way. `--ompchamber-server` is an identity marker: it
135
+ * makes this process recognizable in `ps` output, which is how a starting
136
+ * server decides whether a busy port belongs to OMPChamber before signalling
137
+ * anything.
151
138
  */
152
- export function buildServeInvocation({ pkgRoot, mode, port, host }) {
139
+ export function buildServeInvocation({ pkgRoot, mode, port, host, launchMode = 'daemon' }) {
153
140
  const entry = joinPath(pkgRoot, 'src', 'server', 'index.ts');
154
141
  if (!fs.existsSync(entry)) {
155
142
  throw new Error(`Could not locate the server entry at ${entry}.`);
@@ -159,25 +146,30 @@ export function buildServeInvocation({ pkgRoot, mode, port, host }) {
159
146
  }
160
147
  return {
161
148
  file: resolveBunBin(),
162
- args: [entry],
149
+ args: [entry, '--ompchamber-server'],
163
150
  env: {
164
151
  ...Bun.env,
165
152
  NODE_ENV: mode === 'prod' ? 'production' : 'development',
166
153
  PORT: String(port),
167
154
  HOST: host,
155
+ OMPCHAMBER_LAUNCH_MODE: launchMode,
168
156
  },
169
157
  };
170
158
  }
171
159
 
172
160
  /**
173
161
  * Spawn a detached server, stream its output to the per-port log file and
174
- * persist the registry entry.
162
+ * return the CLI-facing entry description.
163
+ *
164
+ * The instance record is *not* written here: the server writes it once it
165
+ * actually owns the port, so a spawn that fails (port lost to something else)
166
+ * cannot leave a record pointing at a process that never served.
175
167
  */
176
- export function spawnDetachedServer({ pkgRoot, mode, port, host }) {
168
+ export function spawnDetachedServer({ pkgRoot, mode, port, host, launchMode = 'daemon' }) {
177
169
  ensureDataDirs();
178
170
  const logFile = getLogFilePath(port);
179
171
  const fd = fs.openSync(logFile, 'a');
180
- const { file, args, env } = buildServeInvocation({ pkgRoot, mode, port, host });
172
+ const { file, args, env } = buildServeInvocation({ pkgRoot, mode, port, host, launchMode });
181
173
 
182
174
  let child;
183
175
  try {
@@ -194,17 +186,19 @@ export function spawnDetachedServer({ pkgRoot, mode, port, host }) {
194
186
  fs.closeSync(fd);
195
187
  }
196
188
 
197
- const entry = {
198
- pid: child.pid,
199
- port,
200
- host,
201
- mode,
202
- startedAt: new Date().toISOString(),
203
- logFile,
204
- url: `http://${probeHost(host)}:${port}`,
189
+ return {
190
+ child,
191
+ entry: {
192
+ pid: child.pid,
193
+ port,
194
+ host,
195
+ mode,
196
+ launchMode,
197
+ startedAt: new Date().toISOString(),
198
+ logFile,
199
+ url: `http://${probeHost(host)}:${port}`,
200
+ },
205
201
  };
206
- writeRegistry(entry);
207
- return { child, entry };
208
202
  }
209
203
 
210
204
  function sleep(ms) {
@@ -239,7 +233,7 @@ export async function waitForHealth(port, host, timeoutMs = HEALTH_TIMEOUT_MS) {
239
233
 
240
234
  function waitForProcessExit(pid, timeoutMs) {
241
235
  if (!isProcessAlive(pid)) return Promise.resolve(true);
242
- const deadline = Date.now() + Math.max(0, timeoutMs);
236
+ const deadline = Date.now() + timeoutMs;
243
237
  return new Promise((resolve) => {
244
238
  const tick = () => {
245
239
  if (!isProcessAlive(pid)) {
@@ -257,24 +251,40 @@ function waitForProcessExit(pid, timeoutMs) {
257
251
  }
258
252
 
259
253
  /**
260
- * SIGTERM the detached process group, escalate to SIGKILL after the timeout,
261
- * then remove the registry entry. Resolves true once the PID is gone.
254
+ * SIGTERM the process, escalate to SIGKILL after the timeout, then drop the
255
+ * instance record. Resolves true once the PID is gone.
256
+ *
257
+ * Identity is re-checked before signalling: an entry discovered by probe was
258
+ * confirmed by the server itself, but a record-only entry whose PID has since
259
+ * been recycled belongs to a stranger and is never signalled — the stale record
260
+ * is simply removed.
262
261
  */
263
262
  export async function stopInstance(entry, { timeoutMs = STOP_TIMEOUT_MS } = {}) {
264
263
  const port = entry?.port;
265
264
  const pid = Number(entry?.pid);
266
- let gone = true;
265
+ const dropRecord = async () => {
266
+ if (port !== undefined && port !== null) removeInstanceRecord(port);
267
+ };
267
268
 
268
- if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) {
269
- const target = { pid, kill: (signal) => process.kill(pid, signal) };
270
- killChildTree(target, false);
269
+ if (!Number.isInteger(pid) || pid <= 0 || !isProcessAlive(pid)) {
270
+ await dropRecord();
271
+ return true;
272
+ }
273
+
274
+ const confirmedByProbe = entry?.source === 'probe' || entry?.source === 'registry+probe';
275
+ if (!confirmedByProbe && getProcessState(pid) !== 'matched') {
276
+ await dropRecord();
277
+ return true;
278
+ }
279
+
280
+ const target = { pid, kill: (signal) => process.kill(pid, signal) };
281
+ killChildTree(target, false);
282
+ let gone = await waitForProcessExit(pid, timeoutMs);
283
+ if (!gone) {
284
+ killChildTree(target, true);
271
285
  gone = await waitForProcessExit(pid, timeoutMs);
272
- if (!gone) {
273
- killChildTree(target, true);
274
- gone = await waitForProcessExit(pid, timeoutMs);
275
- }
276
286
  }
277
287
 
278
- if (port !== undefined && port !== null) await removeRegistry(port);
288
+ await dropRecord();
279
289
  return gone;
280
290
  }
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useState } from 'preact/hooks';
2
- import type { CSSProperties, TargetedKeyboardEvent } from 'preact';
2
+ import type { CSSProperties, Ref, TargetedKeyboardEvent } from 'preact';
3
3
 
4
4
  /**
5
5
  * Preact hand-roll of the textarea-over-<pre> highlighting editor
@@ -38,6 +38,8 @@ export interface CodeEditorProps {
38
38
  textareaClassName?: string;
39
39
  /** Layout styles for the whole editor; typography must match both layers. */
40
40
  style?: CSSProperties;
41
+ /** The `<pre>` layer that actually wraps the code — gutter alignment measures it. */
42
+ preRef?: Ref<HTMLPreElement>;
41
43
  className?: string;
42
44
  placeholder?: string;
43
45
  readOnly?: boolean;
@@ -73,6 +75,7 @@ export function CodeEditor({
73
75
  preClassName,
74
76
  textareaClassName,
75
77
  style,
78
+ preRef,
76
79
  className,
77
80
  placeholder,
78
81
  readOnly,
@@ -314,6 +317,7 @@ export function CodeEditor({
314
317
  return (
315
318
  <div className={className} style={{ position: 'relative', textAlign: 'left', boxSizing: 'border-box', padding: 0, overflow: 'hidden', ...style }}>
316
319
  <pre
320
+ ref={preRef}
317
321
  className={preClassName}
318
322
  aria-hidden="true"
319
323
  style={{ ...layerStyle, ...contentStyle, position: 'relative', pointerEvents: 'none' }}
@@ -1,5 +1,7 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
1
2
  import type { CSSProperties } from 'preact';
2
3
  import { CodeEditor } from '@/client/components/common/code-editor';
4
+ import { measureWrappedLines, type WrappedLines } from '@/client/components/common/code-surface/measure';
3
5
  import { highlightCode } from '@/shared/lib/code/syntax-highlight';
4
6
  import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
5
7
 
@@ -25,12 +27,26 @@ interface CodeSurfaceProps {
25
27
  editorStyle?: CSSProperties;
26
28
  }
27
29
 
30
+ /** Same measurement, so a re-render does not resize every gutter row. */
31
+ function sameWrappedLines(previous: WrappedLines | null, next: WrappedLines | null): boolean {
32
+ if (previous === next) return true;
33
+ if (!previous || !next || previous.rowHeight !== next.rowHeight) return false;
34
+ return (
35
+ previous.heights.length === next.heights.length &&
36
+ previous.heights.every((height, index) => height === next.heights[index])
37
+ );
38
+ }
39
+
28
40
  /**
29
41
  * The line-number gutter + syntax-highlighted editing surface shared by the
30
42
  * desktop editor and the phone's full-screen editor. Callers own only the
31
43
  * layout classes (padding, font, zoom) that genuinely differ per layout; the
32
44
  * gutter, word-wrap behaviour and Shiki highlighting live here so the two
33
45
  * surfaces cannot drift.
46
+ *
47
+ * The gutter spaces its numbers from the editor's measured wrap layout: with
48
+ * word wrap on, a long line covers several rows and every number below it has
49
+ * to move down by the same amount.
34
50
  */
35
51
  export function CodeSurface({
36
52
  value,
@@ -47,13 +63,44 @@ export function CodeSurface({
47
63
  editorStyle,
48
64
  }: CodeSurfaceProps) {
49
65
  const wrapClass = wordWrap ? WRAP_ON : WRAP_OFF;
66
+ const preRef = useRef<HTMLPreElement | null>(null);
67
+ const [wrapped, setWrapped] = useState<WrappedLines | null>(null);
50
68
  useSyntaxReady();
51
69
 
70
+ // Re-measured on the inputs that move a wrap point: the text, the wrap
71
+ // preference, and the editor's font size / padding.
72
+ const measure = useCallback(() => {
73
+ const pre = preRef.current;
74
+ const next = wordWrap && pre ? measureWrappedLines(pre, value) : null;
75
+ setWrapped((previous) => (sameWrappedLines(previous, next) ? previous : next));
76
+ }, [value, wordWrap, editorPadding, editorStyle?.fontSize]);
77
+
78
+ useLayoutEffect(() => {
79
+ measure();
80
+ }, [measure]);
81
+
82
+ // The wrap width also changes without a re-render — dragging the panel
83
+ // splitter, resizing the window, a zoom that reflows — so the editor's own
84
+ // box drives a re-measure too.
85
+ const measureRef = useRef(measure);
86
+ measureRef.current = measure;
87
+ useEffect(() => {
88
+ const pre = preRef.current;
89
+ if (!pre || typeof ResizeObserver === 'undefined') return;
90
+ const observer = new ResizeObserver(() => measureRef.current());
91
+ observer.observe(pre);
92
+ return () => observer.disconnect();
93
+ }, []);
94
+
52
95
  return (
53
96
  <div className={rootClassName}>
54
97
  <div className={gutterClassName} style={gutterStyle}>
55
98
  {value.split('\n').map((_, i) => (
56
- <div key={i + 1} className={gutterLineClassName}>
99
+ <div
100
+ key={i + 1}
101
+ className={gutterLineClassName}
102
+ style={wrapped ? { height: wrapped.heights[i], flexShrink: 0 } : undefined}
103
+ >
57
104
  {i + 1}
58
105
  </div>
59
106
  ))}
@@ -64,6 +111,7 @@ export function CodeSurface({
64
111
  onValueChange={onValueChange}
65
112
  highlight={(code) => highlightCode(code, language)}
66
113
  padding={editorPadding}
114
+ preRef={preRef}
67
115
  textareaClassName={`focus:outline-none ${wrapClass}`}
68
116
  preClassName={wrapClass}
69
117
  style={editorStyle}
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Wrapped-row measurement for the line-number gutter.
3
+ *
4
+ * The gutter is a column of one-row line numbers, which is only right while a
5
+ * line fits on one row. With word wrap on, the editor lays a long line out over
6
+ * several rows and every number after it would sit above its own line. How many
7
+ * rows a line takes is a layout fact — font metrics, break opportunities, the
8
+ * width the editor actually got — so it is measured rather than estimated: the
9
+ * same lines are laid out once in a hidden mirror that copies the editor's
10
+ * typography, and each line's height is read back.
11
+ */
12
+
13
+ /**
14
+ * The mirror has to reproduce the editor's line breaking exactly, so every
15
+ * property that can move a break is copied from the live editor's computed
16
+ * style instead of being restated (a second copy of the font stack would drift).
17
+ */
18
+ const MIRRORED_PROPERTIES = [
19
+ 'direction',
20
+ 'fontFamily',
21
+ 'fontFeatureSettings',
22
+ 'fontKerning',
23
+ 'fontSize',
24
+ 'fontStyle',
25
+ 'fontVariantLigatures',
26
+ 'fontWeight',
27
+ 'letterSpacing',
28
+ 'overflowWrap',
29
+ 'tabSize',
30
+ 'textRendering',
31
+ 'textTransform',
32
+ 'whiteSpace',
33
+ 'wordBreak',
34
+ 'wordSpacing',
35
+ ] as const;
36
+
37
+ export interface WrappedLines {
38
+ /** Height in px of a single visual row. */
39
+ rowHeight: number;
40
+ /** Height in px each line of the source occupies once wrapped. */
41
+ heights: number[];
42
+ }
43
+
44
+ let mirrorHost: HTMLDivElement | null = null;
45
+ let mirrorLines: HTMLDivElement[] = [];
46
+
47
+ /** Off-screen mirror, reused across measurements (one per page). */
48
+ function getMirrorHost(): HTMLDivElement {
49
+ if (!mirrorHost || !mirrorHost.isConnected) {
50
+ mirrorHost = document.createElement('div');
51
+ mirrorHost.setAttribute('aria-hidden', 'true');
52
+ mirrorHost.style.cssText =
53
+ 'position:fixed;top:0;left:-100000px;visibility:hidden;pointer-events:none;margin:0;border:0;padding:0;box-sizing:border-box;';
54
+ document.body.appendChild(mirrorHost);
55
+ mirrorLines = [];
56
+ }
57
+ return mirrorHost;
58
+ }
59
+
60
+ /**
61
+ * Height each line of `value` occupies in `source`'s wrap layout, or null when
62
+ * the layout is not measurable yet (SSR, zero width, unresolved line-height) —
63
+ * callers then fall back to one row per line.
64
+ */
65
+ export function measureWrappedLines(source: HTMLPreElement, value: string): WrappedLines | null {
66
+ if (typeof document === 'undefined' || !source.isConnected) return null;
67
+ const computed = getComputedStyle(source);
68
+ const rowHeight = Number.parseFloat(computed.lineHeight);
69
+ if (!Number.isFinite(rowHeight) || rowHeight <= 0) return null;
70
+ // The editor's text column: its padding is the gutter's offset, not text area.
71
+ const width =
72
+ source.clientWidth -
73
+ Number.parseFloat(computed.paddingLeft || '0') -
74
+ Number.parseFloat(computed.paddingRight || '0');
75
+ if (width <= 0) return null;
76
+
77
+ const host = getMirrorHost();
78
+ for (const property of MIRRORED_PROPERTIES) host.style[property] = computed[property];
79
+ host.style.lineHeight = `${rowHeight}px`;
80
+ host.style.width = `${width}px`;
81
+
82
+ const lines = value.split('\n');
83
+ while (mirrorLines.length > lines.length) mirrorLines.pop()?.remove();
84
+ while (mirrorLines.length < lines.length) {
85
+ const line = document.createElement('div');
86
+ host.appendChild(line);
87
+ mirrorLines.push(line);
88
+ }
89
+ // Write the whole text first, then read the whole layout: interleaving the
90
+ // two would force a re-layout per line.
91
+ for (let i = 0; i < lines.length; i++) mirrorLines[i].textContent = lines[i];
92
+ const heights = mirrorLines.map((line) => {
93
+ const height = line.getBoundingClientRect().height;
94
+ // An empty line has no line box at all, and sub-pixel wrapping would
95
+ // otherwise leave the gutter fractions of a row out of step.
96
+ return height > rowHeight ? Math.round(height / rowHeight) * rowHeight : rowHeight;
97
+ });
98
+ return { rowHeight, heights };
99
+ }
@@ -1,7 +1,8 @@
1
1
  import { BarChart3, Bot, Files, GitBranch, Globe, Layers, Search, Terminal } from 'lucide-preact';
2
+ import type { ReactNode } from 'preact/compat';
2
3
  import { useGitStatus } from '@/client/hooks/workspace/git-status';
3
4
  import { GIT_STATUS_POLL_MS } from '@/shared/lib/workspace/refresh-cadence';
4
- import type { RightPanelType } from '@/shared/lib/workspace/right-panels';
5
+ import { RIGHT_PANEL_TYPES, type RightPanelType } from '@/shared/lib/workspace/right-panels';
5
6
 
6
7
  interface RightActivityBarProps {
7
8
  activePanel: RightPanelType;
@@ -12,83 +13,55 @@ interface RightActivityBarProps {
12
13
  refreshKey: number;
13
14
  }
14
15
 
16
+ /**
17
+ * Icon and tooltip per view. The *order* is not restated here: it comes from
18
+ * RIGHT_PANEL_TYPES, the same list the phone's right-sidebar tab bar renders,
19
+ * so the two layouts cannot list the views differently.
20
+ */
21
+ const PANEL_META: Record<RightPanelType, { title: string; icon: ReactNode }> = {
22
+ context: { title: 'Context & Telemetry', icon: <Layers size={16} /> },
23
+ files: { title: 'Files', icon: <Files size={16} /> },
24
+ search: { title: 'Search', icon: <Search size={16} /> },
25
+ git: { title: 'Source Control', icon: <GitBranch size={16} /> },
26
+ terminal: { title: 'Terminal (Bun)', icon: <Terminal size={16} /> },
27
+ 'user-browser': { title: 'Browser (Anda)', icon: <Globe size={16} /> },
28
+ browser: { title: 'Browser Agent', icon: <Bot size={16} /> },
29
+ usage: { title: 'Usage', icon: <BarChart3 size={16} /> },
30
+ };
31
+
15
32
  export function RightActivityBar({ activePanel, onChangePanel, isPanelOpen, hasActiveContext, activeProjectPath, refreshKey }: RightActivityBarProps) {
16
33
  const { changes } = useGitStatus(activeProjectPath ?? undefined, '.', refreshKey, hasActiveContext, GIT_STATUS_POLL_MS);
17
34
  const hasGitChanges = changes.length > 0;
18
- const getBtnClass = (panel: RightPanelType) => {
19
- const base = "relative w-full h-10 flex items-center justify-center transition-colors border-l-2";
20
- const isActive = isPanelOpen && activePanel === panel;
21
- return `${base} ${isActive ? 'text-ink border-ink bg-ink/5' : 'text-ink/40 border-transparent hover:text-ink hover:bg-ink/5'}`;
22
- };
23
35
 
24
36
  return (
25
37
  <nav className="w-12 flex-shrink-0 border-l border-ink/10 bg-paper flex flex-col items-center py-3 space-y-2 z-10">
26
38
  {/* Top Icons */}
27
39
  <div className="flex flex-col items-center space-y-1 w-full">
28
- <button
29
- className={getBtnClass('context')}
30
- onClick={() => onChangePanel('context')}
31
- title="Context & Telemetry"
32
- >
33
- <Layers size={16} />
34
- </button>
35
- <button
36
- className={getBtnClass('files')}
37
- onClick={() => onChangePanel('files')}
38
- title="Files"
39
- >
40
- <Files size={16} />
41
- </button>
42
- <button
43
- className={getBtnClass('search')}
44
- onClick={() => onChangePanel('search')}
45
- title="Search"
46
- >
47
- <Search size={16} />
48
- </button>
49
- <button
50
- className={getBtnClass('git')}
51
- onClick={() => onChangePanel('git')}
52
- title="Source Control"
53
- >
54
- <GitBranch size={16} />
55
- {hasGitChanges && (
56
- <span
57
- className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-info"
58
- title="Ada perubahan git"
59
- />
60
- )}
61
- </button>
62
- <button
63
- className={getBtnClass('terminal')}
64
- onClick={() => onChangePanel('terminal')}
65
- title="Terminal (Bun)"
66
- >
67
- <Terminal size={16} />
68
- </button>
69
- <button
70
- className={getBtnClass('user-browser')}
71
- onClick={() => onChangePanel('user-browser')}
72
- title="Browser (Anda)"
73
- >
74
- <Globe size={16} />
75
- </button>
76
- <button
77
- className={getBtnClass('browser')}
78
- onClick={() => onChangePanel('browser')}
79
- title="Browser Agent"
80
- >
81
- <Bot size={16} />
82
- </button>
83
- <button
84
- className={getBtnClass('usage')}
85
- onClick={() => onChangePanel('usage')}
86
- title="Usage"
87
- >
88
- <BarChart3 size={16} />
89
- </button>
40
+ {RIGHT_PANEL_TYPES.map((panel) => {
41
+ const isActive = isPanelOpen && activePanel === panel;
42
+ return (
43
+ <button
44
+ key={panel}
45
+ className={`relative w-full h-10 flex items-center justify-center transition-colors border-l-2 ${
46
+ isActive
47
+ ? 'text-ink border-ink bg-ink/5'
48
+ : 'text-ink/40 border-transparent hover:text-ink hover:bg-ink/5'
49
+ }`}
50
+ onClick={() => onChangePanel(panel)}
51
+ title={PANEL_META[panel].title}
52
+ >
53
+ {PANEL_META[panel].icon}
54
+ {panel === 'git' && hasGitChanges && (
55
+ <span
56
+ className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-info"
57
+ title="Ada perubahan git"
58
+ />
59
+ )}
60
+ </button>
61
+ );
62
+ })}
90
63
  </div>
91
-
64
+
92
65
  <div className="flex-1" />
93
66
  </nav>
94
67
  );