@harborclient/sdk 1.0.23 → 1.0.26

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 (52) hide show
  1. package/dist/components/Autocomplete/SuggestionList.js +1 -1
  2. package/dist/components/Button/index.js +1 -1
  3. package/dist/components/CodeEditor/config.d.ts +4 -0
  4. package/dist/components/CodeEditor/config.d.ts.map +1 -1
  5. package/dist/components/CodeEditor/config.js +3 -2
  6. package/dist/components/CodeEditor/editorChrome.d.ts +23 -0
  7. package/dist/components/CodeEditor/editorChrome.d.ts.map +1 -0
  8. package/dist/components/CodeEditor/editorChrome.js +136 -0
  9. package/dist/components/CodeEditor/index.d.ts +138 -1
  10. package/dist/components/CodeEditor/index.d.ts.map +1 -1
  11. package/dist/components/CodeEditor/index.js +397 -134
  12. package/dist/components/CodeEditor/renderHighlightedPlaceholder.d.ts +32 -0
  13. package/dist/components/CodeEditor/renderHighlightedPlaceholder.d.ts.map +1 -0
  14. package/dist/components/CodeEditor/renderHighlightedPlaceholder.js +105 -0
  15. package/dist/components/CodeEditor/slashCommandHighlighter.d.ts +17 -0
  16. package/dist/components/CodeEditor/slashCommandHighlighter.d.ts.map +1 -0
  17. package/dist/components/CodeEditor/slashCommandHighlighter.js +56 -0
  18. package/dist/components/CodeEditor/syntaxHighlightedPlaceholder.d.ts +15 -0
  19. package/dist/components/CodeEditor/syntaxHighlightedPlaceholder.d.ts.map +1 -0
  20. package/dist/components/CodeEditor/syntaxHighlightedPlaceholder.js +99 -0
  21. package/dist/components/CodeEditor/syntaxLinters.d.ts +10 -0
  22. package/dist/components/CodeEditor/syntaxLinters.d.ts.map +1 -0
  23. package/dist/components/CodeEditor/syntaxLinters.js +36 -0
  24. package/dist/components/EmptyState/index.js +1 -1
  25. package/dist/components/FooterButton/index.js +2 -2
  26. package/dist/components/Resizable/useResizable.d.ts +5 -4
  27. package/dist/components/Resizable/useResizable.d.ts.map +1 -1
  28. package/dist/components/Resizable/useResizable.js +19 -10
  29. package/dist/components/SegmentedTabs/index.d.ts.map +1 -1
  30. package/dist/components/SegmentedTabs/index.js +1 -1
  31. package/dist/components/SegmentedTabs/types.d.ts +3 -1
  32. package/dist/components/SegmentedTabs/types.d.ts.map +1 -1
  33. package/dist/components/Table/index.js +1 -1
  34. package/dist/components/Toolbar/index.d.ts +75 -0
  35. package/dist/components/Toolbar/index.d.ts.map +1 -0
  36. package/dist/components/Toolbar/index.js +38 -0
  37. package/dist/components/VariableInput/index.js +1 -1
  38. package/dist/components/classes.js +2 -2
  39. package/dist/components/forms/classes.d.ts +4 -4
  40. package/dist/components/forms/classes.d.ts.map +1 -1
  41. package/dist/components/forms/classes.js +4 -4
  42. package/dist/components/index.d.ts +3 -1
  43. package/dist/components/index.d.ts.map +1 -1
  44. package/dist/components/index.js +1 -0
  45. package/dist/styles.css +42 -0
  46. package/dist/ui/codeEditorSettings.d.ts +15 -0
  47. package/dist/ui/codeEditorSettings.d.ts.map +1 -1
  48. package/dist/ui/codeEditorSettings.js +40 -0
  49. package/dist/ui/index.d.ts +1 -1
  50. package/dist/ui/index.d.ts.map +1 -1
  51. package/dist/ui/index.js +1 -1
  52. package/package.json +2 -1
@@ -1,122 +1,38 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@harborclient/sdk/jsx-runtime";
2
- import { autocompletion } from '@codemirror/autocomplete';
2
+ import { autocompletion, closeCompletion } from '@codemirror/autocomplete';
3
3
  import { javascript } from '@codemirror/lang-javascript';
4
4
  import { json } from '@codemirror/lang-json';
5
- import { HighlightStyle, StreamLanguage, syntaxHighlighting } from '@codemirror/language';
5
+ import { StreamLanguage } from '@codemirror/language';
6
6
  import { shell } from '@codemirror/legacy-modes/mode/shell';
7
- import { Decoration, EditorView, MatchDecorator, ViewPlugin, hoverTooltip } from '@codemirror/view';
7
+ import { Prec } from '@codemirror/state';
8
+ import { Decoration, EditorView, MatchDecorator, ViewPlugin, hoverTooltip, keymap } from '@codemirror/view';
8
9
  import CodeMirrorImport from '@uiw/react-codemirror';
9
- import { tags } from '@lezer/highlight';
10
- import { useEffect, useId, useMemo, useRef, useState, createElement } from '@harborclient/sdk/react';
10
+ import { useCallback, useEffect, useId, useMemo, useRef, useState, createElement } from '@harborclient/sdk/react';
11
11
  import { getVariableTooltipContent, VARIABLE_NAME_CHARS } from '../../variables/index.js';
12
+ import { normalizeCodeEditorFontSize } from '../../ui/codeEditorSettings.js';
12
13
  import { useCodeEditorConfig } from './config.js';
14
+ import { createBuiltInSyntaxHighlighting, createEditorTheme } from './editorChrome.js';
15
+ import { createSlashCommandHighlighter } from './slashCommandHighlighter.js';
16
+ import { createSyntaxHighlightedPlaceholder } from './syntaxHighlightedPlaceholder.js';
17
+ import { createJavascriptSyntaxLinter, createJsonSyntaxLinter } from './syntaxLinters.js';
13
18
  import { getCodeEditorThemeExtension } from './themes.js';
14
19
  export { CODE_EDITOR_THEME_OPTIONS } from './themes.js';
15
- const lightHighlight = HighlightStyle.define([
16
- { tag: tags.propertyName, color: '#881391' },
17
- { tag: tags.string, color: '#c41a16' },
18
- { tag: tags.number, color: '#1c00cf' },
19
- { tag: tags.bool, color: '#1c00cf' },
20
- { tag: tags.null, color: '#1c00cf' },
21
- { tag: tags.keyword, color: '#881391' },
22
- { tag: tags.bracket, color: 'var(--mac-text)' },
23
- { tag: tags.punctuation, color: 'var(--mac-muted)' },
24
- { tag: tags.comment, color: 'var(--mac-muted)', fontStyle: 'italic' }
25
- ]);
26
- const darkHighlight = HighlightStyle.define([
27
- { tag: tags.propertyName, color: '#ff7ab2' },
28
- { tag: tags.string, color: '#ff8170' },
29
- { tag: tags.number, color: '#78dce8' },
30
- { tag: tags.bool, color: '#78dce8' },
31
- { tag: tags.null, color: '#78dce8' },
32
- { tag: tags.keyword, color: '#ff7ab2' },
33
- { tag: tags.bracket, color: 'var(--mac-text)' },
34
- { tag: tags.punctuation, color: 'var(--mac-muted)' },
35
- { tag: tags.comment, color: 'var(--mac-muted)', fontStyle: 'italic' }
36
- ]);
37
- const editorTheme = EditorView.theme({
38
- '&': {
39
- backgroundColor: 'transparent',
40
- color: 'var(--mac-text)'
41
- },
42
- '.cm-scroller': {
43
- overflow: 'auto',
44
- fontFamily: 'var(--font-mono)'
45
- },
46
- '.cm-content': {
47
- padding: '8px 0',
48
- fontFamily: 'var(--font-mono)',
49
- fontSize: '14px',
50
- caretColor: 'var(--mac-accent)'
51
- },
52
- '.cm-line': {
53
- padding: '0 8px'
54
- },
55
- '&.cm-focused': {
56
- outline: 'none'
57
- },
58
- '&.cm-focused .cm-cursor': {
59
- borderLeftColor: 'var(--mac-accent)'
60
- },
61
- '.cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection': {
62
- backgroundColor: 'var(--mac-selection) !important'
63
- },
64
- '.cm-gutters': {
65
- backgroundColor: 'transparent',
66
- color: 'var(--mac-muted)',
67
- border: 'none'
68
- },
69
- '.cm-activeLineGutter': {
70
- backgroundColor: 'var(--mac-selection)'
71
- },
72
- '.cm-activeLine': {
73
- backgroundColor: 'color-mix(in srgb, var(--mac-selection) 45%, transparent)'
74
- },
75
- '.cm-variable-token': {
76
- color: '#32D2E2'
77
- },
78
- '.cm-tooltip.cm-tooltip-hover': {
79
- border: '1px solid var(--mac-separator)',
80
- backgroundColor: 'var(--mac-surface)',
81
- borderRadius: '6px',
82
- boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
83
- },
84
- '.cm-variable-tooltip': {
85
- display: 'flex',
86
- flexDirection: 'column',
87
- gap: '6px',
88
- padding: '8px 12px',
89
- fontSize: '14px',
90
- color: 'var(--mac-text)'
91
- },
92
- '.cm-variable-tooltip-muted': {
93
- color: 'var(--mac-muted)'
94
- },
95
- '.cm-variable-tooltip-edit': {
96
- alignSelf: 'flex-start',
97
- background: 'none',
98
- border: 'none',
99
- padding: '0',
100
- cursor: 'pointer',
101
- fontSize: '14px',
102
- color: 'var(--mac-accent)'
103
- },
104
- '.cm-tooltip.cm-tooltip-autocomplete': {
105
- border: '1px solid var(--mac-separator)',
106
- backgroundColor: 'var(--mac-surface)',
107
- borderRadius: '6px',
108
- boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
109
- fontSize: '14px'
110
- },
111
- '.cm-completionLabel': {
112
- fontFamily: 'var(--font-mono)'
113
- },
114
- '.cm-completionDetail': {
115
- color: 'var(--mac-muted)',
116
- fontStyle: 'normal',
117
- marginLeft: '8px'
118
- }
119
- });
20
+ /**
21
+ * Clamps selection offsets to the current document length.
22
+ *
23
+ * @param docLength - Current document length in characters.
24
+ * @param selection - Selection offsets to clamp.
25
+ * @returns Offsets safe to dispatch into CodeMirror.
26
+ */
27
+ function clampSelection(docLength, selection) {
28
+ const maxOffset = Math.max(0, docLength);
29
+ return {
30
+ anchor: Math.min(Math.max(0, selection.anchor), maxOffset),
31
+ head: Math.min(Math.max(0, selection.head), maxOffset)
32
+ };
33
+ }
34
+ /** Debounce interval for {@link Props.onViewStateChange} notifications. */
35
+ const VIEW_STATE_DEBOUNCE_MS = 300;
120
36
  const variableMatcher = new MatchDecorator({
121
37
  regexp: new RegExp(`\\{\\{\\s*([${VARIABLE_NAME_CHARS}]+)\\s*\\}\\}`, 'g'),
122
38
  decoration: Decoration.mark({ class: 'cm-variable-token' })
@@ -140,6 +56,110 @@ const variableHighlighter = ViewPlugin.fromClass(class {
140
56
  this.decorations = variableMatcher.updateDeco(update, this.decorations);
141
57
  }
142
58
  }, { decorations: (v) => v.decorations });
59
+ /** Matches a slash command at the start of a line with optional arguments. */
60
+ const SLASH_COMMAND_LINE_PATTERN = /^(\s*)\/(\w+)(?:[ \t]+(.*))?$/;
61
+ /**
62
+ * Returns a parsed slash command on a line when the name is registered.
63
+ *
64
+ * @param lineText - Full line text without the trailing newline.
65
+ * @param lineFrom - Document offset of the line start.
66
+ * @param lineNumber - 1-based line number.
67
+ * @param commands - Registered slash commands.
68
+ */
69
+ function parseSlashCommandLine(lineText, lineFrom, lineNumber, commands) {
70
+ const match = lineText.match(SLASH_COMMAND_LINE_PATTERN);
71
+ if (!match) {
72
+ return null;
73
+ }
74
+ const command = match[2];
75
+ if (command == null || !commands.some((entry) => entry.name === command)) {
76
+ return null;
77
+ }
78
+ const leading = match[1] ?? '';
79
+ const slashIndex = leading.length;
80
+ const from = lineFrom + slashIndex;
81
+ const to = lineFrom + lineText.length;
82
+ return {
83
+ command,
84
+ args: (match[3] ?? '').trim(),
85
+ line: lineNumber,
86
+ from,
87
+ to
88
+ };
89
+ }
90
+ /**
91
+ * Builds a CodeMirror completion source for registered slash commands at line start.
92
+ *
93
+ * @param commands - Slash commands to offer after `/`.
94
+ */
95
+ function createSlashCommandCompletionSource(commands) {
96
+ /**
97
+ * Returns slash command completions when the caret follows `/` at line start.
98
+ *
99
+ * @param context - CodeMirror completion context at the cursor.
100
+ */
101
+ return (context) => {
102
+ const word = context.matchBefore(/^\s*\/\w*/);
103
+ if (!word || word.text.length === 0) {
104
+ return null;
105
+ }
106
+ const partial = word.text.replace(/^\s*\//, '');
107
+ const options = commands
108
+ .filter((entry) => entry.name.startsWith(partial))
109
+ .map((entry) => ({
110
+ label: `/${entry.name}`,
111
+ type: 'keyword',
112
+ detail: entry.description,
113
+ apply: `/${entry.name} `
114
+ }));
115
+ if (options.length === 0) {
116
+ return null;
117
+ }
118
+ return { from: word.from, options };
119
+ };
120
+ }
121
+ /**
122
+ * Returns an Enter keymap that triggers registered slash commands before default newline handling.
123
+ *
124
+ * @param commands - Slash commands to recognize.
125
+ * @param onSlashCommand - Host callback invoked with parsed trigger details.
126
+ */
127
+ function slashCommandEnterHandler(commands, onSlashCommand) {
128
+ return Prec.highest(keymap.of([
129
+ {
130
+ key: 'Enter',
131
+ /**
132
+ * Opens host slash-command UI when Enter is pressed on a complete command line.
133
+ *
134
+ * @param view - CodeMirror editor view instance.
135
+ */
136
+ run: (view) => {
137
+ const pos = view.state.selection.main.head;
138
+ const line = view.state.doc.lineAt(pos);
139
+ const parsed = parseSlashCommandLine(line.text, line.from, line.number, commands);
140
+ if (!parsed) {
141
+ return false;
142
+ }
143
+ const coords = view.coordsAtPos(parsed.from);
144
+ if (!coords) {
145
+ return false;
146
+ }
147
+ // Dismiss the autocomplete popup so its Enter binding does not also fire.
148
+ closeCompletion(view);
149
+ onSlashCommand({
150
+ ...parsed,
151
+ coords: {
152
+ top: coords.top,
153
+ left: coords.left,
154
+ bottom: coords.bottom,
155
+ right: coords.right
156
+ }
157
+ });
158
+ return true;
159
+ }
160
+ }
161
+ ]));
162
+ }
143
163
  /**
144
164
  * Finds the {{variable}} token at a document position, if any.
145
165
  *
@@ -274,10 +294,10 @@ function variableTooltipEscapeHandler(isOpen, onDismiss, getValidationDescribedB
274
294
  /**
275
295
  * Builds a hover tooltip extension for {{variable}} tokens.
276
296
  *
277
- * @param variables - Collection-scoped variables for resolution.
278
- * @param onEditVariable - Optional callback to open collection settings.
297
+ * @param getVariables - Returns the current collection-scoped variables.
298
+ * @param getOnEditVariable - Returns the optional edit callback.
279
299
  */
280
- function variableTooltip(variables, onEditVariable) {
300
+ function variableTooltip(getVariables, getOnEditVariable) {
281
301
  return hoverTooltip((view, pos) => {
282
302
  const match = findVariableAtPos(view.state.doc, pos);
283
303
  if (!match)
@@ -287,21 +307,27 @@ function variableTooltip(variables, onEditVariable) {
287
307
  end: match.end,
288
308
  above: true,
289
309
  create() {
290
- return { dom: buildVariableTooltipDom(match.key, variables, onEditVariable) };
310
+ return {
311
+ dom: buildVariableTooltipDom(match.key, getVariables(), getOnEditVariable())
312
+ };
291
313
  }
292
314
  };
293
315
  });
294
316
  }
317
+ /** Size of the native resize-y grip hit target in the bottom-right corner. */
318
+ const RESIZE_GRIP_PX = 16;
295
319
  /**
296
320
  * CodeMirror wrapper for editable request bodies and read-only response views.
297
321
  *
298
322
  * Styling relies on host CSS variables (`--mac-*`, `--font-mono`) and the `.app-no-drag`
299
323
  * class defined in HarborClient `styles.css`.
300
324
  */
301
- export function CodeEditor({ value, onChange, language = 'text', readOnly = false, placeholder, minHeight = '144px', className = '', variables, onEditVariable, completionSource, themeOverride, setupOverride, id, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-invalid': ariaInvalid, 'aria-describedby': ariaDescribedBy }) {
325
+ export function CodeEditor({ value, onChange, language = 'text', readOnly = false, editable, placeholder, placeholderHighlight = false, minHeight = '144px', height, onHeightChange, initialScrollTop, initialSelection, onViewStateChange, className = '', variables, onEditVariable, completionSource, slashCommands, onSlashCommand, lint = true, themeOverride, setupOverride, fontSize, id, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-invalid': ariaInvalid, 'aria-describedby': ariaDescribedBy }) {
302
326
  const config = useCodeEditorConfig();
303
327
  const resolvedTheme = themeOverride ?? config.theme;
304
328
  const resolvedSetup = setupOverride ?? (readOnly ? null : config.setup);
329
+ const resolvedFontSize = normalizeCodeEditorFontSize(fontSize ?? config.fontSize);
330
+ const resolvedEditable = editable ?? !readOnly;
305
331
  const [isDark, setIsDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches);
306
332
  const [selectionTooltip, setSelectionTooltip] = useState(null);
307
333
  const selectionTooltipRef = useRef(selectionTooltip);
@@ -310,8 +336,177 @@ export function CodeEditor({ value, onChange, language = 'text', readOnly = fals
310
336
  setSelectionTooltipRef.current = setSelectionTooltip;
311
337
  const ariaDescribedByRef = useRef(ariaDescribedBy);
312
338
  ariaDescribedByRef.current = ariaDescribedBy;
339
+ const onSlashCommandRef = useRef(onSlashCommand);
340
+ onSlashCommandRef.current = onSlashCommand;
341
+ const onChangeRef = useRef(onChange);
342
+ onChangeRef.current = onChange;
343
+ const completionSourceRef = useRef(completionSource);
344
+ completionSourceRef.current = completionSource;
345
+ const variablesRef = useRef(variables);
346
+ variablesRef.current = variables;
347
+ const onEditVariableRef = useRef(onEditVariable);
348
+ onEditVariableRef.current = onEditVariable;
349
+ const hasVariables = variables != null;
350
+ const hasCompletionSource = completionSource != null;
313
351
  const tooltipId = useId();
352
+ const wrapperRef = useRef(null);
353
+ const onHeightChangeRef = useRef(onHeightChange);
354
+ onHeightChangeRef.current = onHeightChange;
355
+ const isUserResizingRef = useRef(false);
356
+ const editorViewRef = useRef(null);
357
+ const onViewStateChangeRef = useRef(onViewStateChange);
358
+ onViewStateChangeRef.current = onViewStateChange;
359
+ const initialScrollTopRef = useRef(initialScrollTop);
360
+ initialScrollTopRef.current = initialScrollTop;
361
+ const initialSelectionRef = useRef(initialSelection);
362
+ initialSelectionRef.current = initialSelection;
363
+ const viewStateDebounceRef = useRef(undefined);
364
+ const scrollListenerCleanupRef = useRef(null);
365
+ const scheduleViewStateFlushRef = useRef(() => undefined);
314
366
  const getValidationDescribedBy = () => ariaDescribedByRef.current;
367
+ /**
368
+ * Reads the current scroll and selection snapshot from an editor view.
369
+ *
370
+ * @param view - Live CodeMirror view.
371
+ * @returns Rounded scroll offset and selection offsets.
372
+ */
373
+ const readViewState = useCallback((view) => {
374
+ const selection = view.state.selection.main;
375
+ return {
376
+ scrollTop: Math.max(0, Math.round(view.scrollDOM.scrollTop)),
377
+ selection: { anchor: selection.anchor, head: selection.head }
378
+ };
379
+ }, []);
380
+ /**
381
+ * Notifies the host of the latest scroll/selection snapshot.
382
+ */
383
+ const flushViewState = useCallback(() => {
384
+ const view = editorViewRef.current;
385
+ if (!view || !onViewStateChangeRef.current) {
386
+ return;
387
+ }
388
+ onViewStateChangeRef.current(readViewState(view));
389
+ }, [readViewState]);
390
+ /**
391
+ * Debounces view-state persistence while the user scrolls or changes selection.
392
+ */
393
+ useEffect(() => {
394
+ scheduleViewStateFlushRef.current = () => {
395
+ if (viewStateDebounceRef.current) {
396
+ clearTimeout(viewStateDebounceRef.current);
397
+ }
398
+ viewStateDebounceRef.current = setTimeout(() => {
399
+ flushViewState();
400
+ }, VIEW_STATE_DEBOUNCE_MS);
401
+ };
402
+ }, [flushViewState]);
403
+ /**
404
+ * Restores persisted scroll/selection and wires scroll tracking when the view is created.
405
+ */
406
+ const stableOnCreateEditor = useCallback((view) => {
407
+ editorViewRef.current = view;
408
+ const restoredSelection = initialSelectionRef.current;
409
+ if (restoredSelection) {
410
+ const clamped = clampSelection(view.state.doc.length, restoredSelection);
411
+ view.dispatch({
412
+ selection: { anchor: clamped.anchor, head: clamped.head }
413
+ });
414
+ }
415
+ const restoredScrollTop = initialScrollTopRef.current;
416
+ if (restoredScrollTop != null && Number.isFinite(restoredScrollTop) && restoredScrollTop >= 0) {
417
+ view.scrollDOM.scrollTop = restoredScrollTop;
418
+ }
419
+ /**
420
+ * Schedules persistence when the user scrolls the editor content.
421
+ */
422
+ const handleScroll = () => {
423
+ scheduleViewStateFlushRef.current();
424
+ };
425
+ view.scrollDOM.addEventListener('scroll', handleScroll, { passive: true });
426
+ scrollListenerCleanupRef.current = () => {
427
+ view.scrollDOM.removeEventListener('scroll', handleScroll);
428
+ };
429
+ }, []);
430
+ /**
431
+ * Flushes scroll/selection on unmount so collapsed script rows retain view state.
432
+ */
433
+ useEffect(() => {
434
+ return () => {
435
+ if (viewStateDebounceRef.current) {
436
+ clearTimeout(viewStateDebounceRef.current);
437
+ }
438
+ scrollListenerCleanupRef.current?.();
439
+ scrollListenerCleanupRef.current = null;
440
+ flushViewState();
441
+ editorViewRef.current = null;
442
+ };
443
+ }, [flushViewState]);
444
+ /**
445
+ * Persists wrapper height only after the user finishes a native resize-y drag on the grip.
446
+ * Ignores mount and layout settling so height props do not reconfigure CodeMirror in a loop.
447
+ */
448
+ useEffect(() => {
449
+ if (!onHeightChange) {
450
+ return;
451
+ }
452
+ const wrapper = wrapperRef.current;
453
+ if (!wrapper) {
454
+ return;
455
+ }
456
+ /**
457
+ * Marks resize drags that begin on the native bottom-right grip.
458
+ *
459
+ * @param event - Pointer down on the editor wrapper.
460
+ */
461
+ const handlePointerDown = (event) => {
462
+ const rect = wrapper.getBoundingClientRect();
463
+ const onGrip = event.clientX >= rect.right - RESIZE_GRIP_PX &&
464
+ event.clientY >= rect.bottom - RESIZE_GRIP_PX;
465
+ if (onGrip) {
466
+ isUserResizingRef.current = true;
467
+ }
468
+ };
469
+ /**
470
+ * Reports the final wrapper height after a completed user resize drag.
471
+ */
472
+ const handlePointerUp = () => {
473
+ if (!isUserResizingRef.current) {
474
+ return;
475
+ }
476
+ isUserResizingRef.current = false;
477
+ const nextHeight = Math.round(wrapper.getBoundingClientRect().height);
478
+ onHeightChangeRef.current?.(nextHeight);
479
+ };
480
+ wrapper.addEventListener('pointerdown', handlePointerDown);
481
+ window.addEventListener('pointerup', handlePointerUp);
482
+ return () => {
483
+ wrapper.removeEventListener('pointerdown', handlePointerDown);
484
+ window.removeEventListener('pointerup', handlePointerUp);
485
+ };
486
+ }, [onHeightChange]);
487
+ /**
488
+ * Whether to render a muted syntax-highlighted ghost layer instead of plain placeholder text.
489
+ */
490
+ const useHighlightedPlaceholder = Boolean(placeholderHighlight && placeholder && language === 'javascript');
491
+ /**
492
+ * Stable onChange wrapper so @uiw/react-codemirror does not reconfigure on every parent render.
493
+ */
494
+ const stableOnChange = useCallback((nextValue) => {
495
+ onChangeRef.current?.(nextValue);
496
+ }, []);
497
+ /**
498
+ * Stable completion delegator reading the latest host source from a ref.
499
+ */
500
+ const stableCompletionSource = useMemo(() => (context) => completionSourceRef.current?.(context) ?? null, []);
501
+ /**
502
+ * Stable variable accessors for tooltip extensions.
503
+ */
504
+ const getVariables = useCallback(() => variablesRef.current ?? [], []);
505
+ const getOnEditVariable = useCallback(() => onEditVariableRef.current, []);
506
+ /**
507
+ * Memoized editor chrome theme keyed by font size only.
508
+ */
509
+ const editorThemeExt = useMemo(() => createEditorTheme(resolvedFontSize), [resolvedFontSize]);
315
510
  /**
316
511
  * Tracks system dark mode so syntax highlighting matches the active theme.
317
512
  */
@@ -321,35 +516,73 @@ export function CodeEditor({ value, onChange, language = 'text', readOnly = fals
321
516
  media.addEventListener('change', handleChange);
322
517
  return () => media.removeEventListener('change', handleChange);
323
518
  }, []);
519
+ /**
520
+ * Stable selection listener that debounces view-state persistence.
521
+ */
522
+ const viewStateSelectionListener = useMemo(() => EditorView.updateListener.of((update) => {
523
+ if (!onViewStateChangeRef.current || !update.selectionSet) {
524
+ return;
525
+ }
526
+ scheduleViewStateFlushRef.current();
527
+ }), []);
324
528
  /**
325
529
  * Assembles CodeMirror extensions for language mode, theme, and optional variable tooling.
326
530
  */
327
531
  const extensions = useMemo(() => {
328
- const next = [EditorView.lineWrapping, editorTheme];
532
+ const next = [EditorView.lineWrapping, editorThemeExt];
533
+ if (onViewStateChange != null) {
534
+ next.push(viewStateSelectionListener);
535
+ }
329
536
  const themeExtension = getCodeEditorThemeExtension(resolvedTheme);
330
537
  if (themeExtension) {
331
538
  next.push(themeExtension);
332
539
  }
333
540
  else {
334
- next.push(syntaxHighlighting(isDark ? darkHighlight : lightHighlight));
541
+ next.push(createBuiltInSyntaxHighlighting(isDark));
335
542
  }
336
543
  if (language === 'json') {
337
544
  next.push(json());
338
545
  }
339
546
  if (language === 'javascript') {
340
547
  next.push(javascript());
341
- if (completionSource) {
548
+ // Register the slash Enter handler before autocompletion so that, at equal
549
+ // Prec.highest precedence, this handler wins the tie and fires before the
550
+ // completion keymap's Enter->accept binding.
551
+ if (slashCommands && slashCommands.length > 0) {
552
+ next.push(slashCommandEnterHandler(slashCommands, (trigger) => {
553
+ onSlashCommandRef.current?.(trigger);
554
+ }));
555
+ }
556
+ const completionOverrides = [];
557
+ if (slashCommands && slashCommands.length > 0) {
558
+ completionOverrides.push(createSlashCommandCompletionSource(slashCommands));
559
+ }
560
+ if (hasCompletionSource) {
561
+ completionOverrides.push(stableCompletionSource);
562
+ }
563
+ if (completionOverrides.length > 0) {
342
564
  next.push(autocompletion({
343
565
  activateOnTyping: true,
344
- override: [completionSource]
566
+ override: completionOverrides
345
567
  }));
346
568
  }
569
+ if (slashCommands && slashCommands.length > 0) {
570
+ next.push(createSlashCommandHighlighter(slashCommands));
571
+ }
347
572
  }
348
573
  if (language === 'shell') {
349
574
  next.push(StreamLanguage.define(shell));
350
575
  }
351
- if (variables) {
352
- next.push(variableHighlighter, variableTooltip(variables, onEditVariable), variableSelectionTooltip(tooltipId, (state) => {
576
+ if (lint) {
577
+ if (language === 'json') {
578
+ next.push(createJsonSyntaxLinter());
579
+ }
580
+ if (language === 'javascript') {
581
+ next.push(createJavascriptSyntaxLinter());
582
+ }
583
+ }
584
+ if (hasVariables) {
585
+ next.push(variableHighlighter, variableTooltip(getVariables, getOnEditVariable), variableSelectionTooltip(tooltipId, (state) => {
353
586
  setSelectionTooltipRef.current(state);
354
587
  }, getValidationDescribedBy), variableTooltipEscapeHandler(() => selectionTooltipRef.current != null, () => {
355
588
  setSelectionTooltipRef.current(null);
@@ -369,20 +602,38 @@ export function CodeEditor({ value, onChange, language = 'text', readOnly = fals
369
602
  if (Object.keys(contentAttrs).length > 0) {
370
603
  next.push(EditorView.contentAttributes.of(contentAttrs));
371
604
  }
605
+ if (useHighlightedPlaceholder && placeholder) {
606
+ next.push(...createSyntaxHighlightedPlaceholder(placeholder, {
607
+ fontSize: resolvedFontSize,
608
+ isDark,
609
+ theme: resolvedTheme,
610
+ slashCommands
611
+ }));
612
+ }
372
613
  return next;
373
614
  }, [
615
+ editorThemeExt,
374
616
  resolvedTheme,
375
617
  isDark,
376
618
  language,
377
- variables,
378
- onEditVariable,
379
- completionSource,
619
+ lint,
620
+ hasVariables,
621
+ hasCompletionSource,
622
+ stableCompletionSource,
623
+ getVariables,
624
+ getOnEditVariable,
625
+ slashCommands,
626
+ useHighlightedPlaceholder,
627
+ placeholder,
628
+ resolvedFontSize,
380
629
  id,
381
630
  ariaLabel,
382
631
  ariaLabelledBy,
383
632
  ariaInvalid,
384
633
  ariaDescribedBy,
385
- tooltipId
634
+ tooltipId,
635
+ onViewStateChange,
636
+ viewStateSelectionListener
386
637
  ]);
387
638
  /**
388
639
  * Resolves CodeMirror basicSetup from persisted settings or read-only defaults.
@@ -412,30 +663,42 @@ export function CodeEditor({ value, onChange, language = 'text', readOnly = fals
412
663
  indentOnInput: false
413
664
  };
414
665
  }
666
+ // Disable the built-in autocompletion and completion keymap: this component
667
+ // installs its own autocompletion() for JavaScript editors, ordered after the
668
+ // slash-command Enter handler. Leaving basicSetup's autocompletion enabled would
669
+ // inject a competing Prec.highest Enter->acceptCompletion binding ahead of ours,
670
+ // swallowing the first Enter on a /ask line.
415
671
  return {
416
672
  lineNumbers: resolvedSetup.lineNumbers,
417
673
  foldGutter: resolvedSetup.foldGutter,
418
674
  highlightActiveLine: resolvedSetup.highlightActiveLine,
419
- highlightActiveLineGutter: resolvedSetup.highlightActiveLineGutter
675
+ highlightActiveLineGutter: resolvedSetup.highlightActiveLineGutter,
676
+ highlightSelectionMatches: false,
677
+ autocompletion: false,
678
+ completionKeymap: false
420
679
  };
421
680
  }, [resolvedSetup, readOnly]);
422
681
  const wrapperClassName = readOnly
423
- ? `hc-code-editor overflow-hidden rounded-md bg-control shadow-[inset_0_0.5px_1px_rgba(0,0,0,0.06)] app-no-drag ${className}`
424
- : `hc-code-editor min-h-36 resize-y overflow-hidden rounded-md border border-separator bg-control shadow-[inset_0_0.5px_1px_rgba(0,0,0,0.06)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--mac-accent)_35%,transparent),inset_0_0.5px_1px_rgba(0,0,0,0.06)] app-no-drag ${className}`;
682
+ ? `hc-code-editor overflow-hidden rounded-lg bg-control shadow-[inset_0_0.5px_1px_rgba(0,0,0,0.06)] app-no-drag ${className}`
683
+ : `hc-code-editor min-h-36 resize-y overflow-hidden rounded-lg border border-separator bg-control shadow-[inset_0_0.5px_1px_rgba(0,0,0,0.06)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--mac-accent)_35%,transparent),inset_0_0.5px_1px_rgba(0,0,0,0.06)] app-no-drag ${className}`;
425
684
  const selectionTooltipContent = selectionTooltip
426
685
  ? getVariableTooltipContent(selectionTooltip.key, variables ?? [])
427
686
  : null;
428
- return (_jsxs("div", { className: wrapperClassName, children: [createElement(CodeMirrorImport, {
687
+ const wrapperStyle = height ? { height } : undefined;
688
+ const shouldTrackViewState = onViewStateChange != null || initialScrollTop != null || initialSelection != null;
689
+ return (_jsxs("div", { ref: wrapperRef, className: wrapperClassName, style: wrapperStyle, children: [createElement(CodeMirrorImport, {
429
690
  value,
430
- onChange: readOnly ? undefined : onChange,
691
+ onChange: readOnly ? undefined : stableOnChange,
431
692
  extensions,
432
693
  theme: 'none',
433
- editable: !readOnly,
694
+ editable: resolvedEditable,
434
695
  readOnly,
435
- placeholder,
696
+ placeholder: useHighlightedPlaceholder ? undefined : placeholder,
436
697
  minHeight,
437
- basicSetup
438
- }), selectionTooltip && selectionTooltipContent && variables ? (_jsxs("div", { id: tooltipId, role: "tooltip", className: "hc-code-editor-tooltip pointer-events-auto fixed z-50 flex max-w-sm -translate-x-1/2 -translate-y-full flex-col gap-1.5 rounded-md border border-separator bg-surface px-3 py-2 text-[14px] text-text shadow-md app-no-drag", style: { top: selectionTooltip.top - 4, left: selectionTooltip.left }, children: [_jsx("span", { className: selectionTooltipContent.muted
698
+ ...(height ? { height: '100%' } : {}),
699
+ basicSetup,
700
+ ...(shouldTrackViewState ? { onCreateEditor: stableOnCreateEditor } : {})
701
+ }), selectionTooltip && selectionTooltipContent && variables ? (_jsxs("div", { id: tooltipId, role: "tooltip", className: "hc-code-editor-tooltip pointer-events-auto fixed z-50 flex max-w-sm -translate-x-1/2 -translate-y-full flex-col gap-1.5 rounded-lg border border-separator bg-surface px-3 py-2 text-[14px] text-text shadow-md app-no-drag", style: { top: selectionTooltip.top - 4, left: selectionTooltip.left }, children: [_jsx("span", { className: selectionTooltipContent.muted
439
702
  ? 'hc-code-editor-tooltip-text text-muted'
440
703
  : 'hc-code-editor-tooltip-text', children: selectionTooltipContent.text }), onEditVariable ? (_jsx("button", { type: "button", className: "hc-code-editor-tooltip-edit self-start text-[14px] text-accent hover:underline", "aria-label": `Edit value for ${selectionTooltip.key}`, onMouseDown: (event) => {
441
704
  event.preventDefault();