@xh/hoist 86.2.0 → 86.3.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 (66) hide show
  1. package/.gitattributes +4 -0
  2. package/CHANGELOG.md +58 -0
  3. package/admin/AppModel.ts +3 -4
  4. package/admin/tabs/activity/tracking/ActivityTrackingModel.ts +56 -6
  5. package/admin/tabs/activity/tracking/ActivityTrackingPanel.ts +9 -26
  6. package/admin/tabs/clients/ClientsModel.ts +4 -1
  7. package/admin/tabs/clients/activity/ClientDetailModel.ts +5 -1
  8. package/admin/tabs/userData/roles/RolePanel.ts +2 -2
  9. package/appcontainer/RouterModel.ts +6 -6
  10. package/build/types/admin/AppModel.d.ts +3 -4
  11. package/build/types/admin/tabs/activity/tracking/ActivityTrackingModel.d.ts +14 -3
  12. package/build/types/admin/tabs/clients/ClientsModel.d.ts +3 -0
  13. package/build/types/appcontainer/RouterModel.d.ts +5 -5
  14. package/build/types/cmp/grid/Grid.d.ts +2 -1
  15. package/build/types/cmp/grid/GridModel.d.ts +2 -2
  16. package/build/types/cmp/grid/Types.d.ts +8 -1
  17. package/build/types/cmp/input/SegmentedControlOption.d.ts +12 -0
  18. package/build/types/core/HoistAppModel.d.ts +2 -3
  19. package/build/types/core/types/Types.d.ts +12 -0
  20. package/build/types/desktop/cmp/filter/FilterChooser.d.ts +5 -0
  21. package/build/types/desktop/cmp/filter/PopoverFilterChooser.d.ts +3 -1
  22. package/build/types/desktop/cmp/grid/impl/colchooser/ColChooserModel.d.ts +2 -2
  23. package/build/types/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.d.ts +9 -1
  24. package/build/types/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTabModel.d.ts +8 -2
  25. package/build/types/desktop/cmp/input/Select.d.ts +8 -0
  26. package/build/types/kit/react-dropzone/index.d.ts +1 -0
  27. package/build/types/kit/swiper/index.d.ts +4 -3
  28. package/build/types/mobile/cmp/grid/impl/ColChooserModel.d.ts +2 -2
  29. package/build/types/mobile/cmp/input/Select.d.ts +8 -0
  30. package/cmp/grid/Grid.ts +10 -2
  31. package/cmp/grid/GridModel.ts +4 -3
  32. package/cmp/grid/Types.ts +9 -1
  33. package/cmp/grid/impl/MenuSupport.ts +1 -1
  34. package/cmp/input/SegmentedControlOption.ts +14 -0
  35. package/cmp/store/impl/StoreFilterFieldImplModel.ts +5 -6
  36. package/core/HoistAppModel.ts +2 -3
  37. package/core/types/Types.ts +14 -5
  38. package/desktop/cmp/filechooser/FileChooser.ts +6 -3
  39. package/desktop/cmp/filechooser/FileChooserModel.ts +12 -5
  40. package/desktop/cmp/filter/FilterChooser.scss +41 -0
  41. package/desktop/cmp/filter/FilterChooser.ts +147 -7
  42. package/desktop/cmp/filter/PopoverFilterChooser.ts +7 -84
  43. package/desktop/cmp/grid/editors/impl/InlineEditorModel.ts +6 -1
  44. package/desktop/cmp/grid/find/impl/GridFindFieldImplModel.ts +3 -5
  45. package/desktop/cmp/grid/impl/colchooser/ColChooserModel.ts +2 -2
  46. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRow.ts +12 -12
  47. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.ts +18 -0
  48. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTab.scss +5 -0
  49. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTab.ts +13 -2
  50. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomTabModel.ts +97 -14
  51. package/desktop/cmp/input/SegmentedControl.ts +7 -3
  52. package/desktop/cmp/input/Select.ts +24 -16
  53. package/docs/routing.md +4 -2
  54. package/docs/upgrade-notes/v86-upgrade-notes.md +73 -8
  55. package/kit/react-dropzone/index.ts +1 -0
  56. package/mcp/README.md +12 -0
  57. package/mcp/data/ts-registry.spec.ts +156 -0
  58. package/mcp/data/ts-registry.ts +13 -6
  59. package/mcp/formatters/typescript.ts +7 -3
  60. package/mcp/util/paths.ts +21 -0
  61. package/mobile/cmp/grid/impl/ColChooserModel.ts +2 -2
  62. package/mobile/cmp/input/SegmentedControl.ts +7 -3
  63. package/mobile/cmp/input/Select.ts +21 -15
  64. package/mobx/README.md +2 -1
  65. package/package.json +5 -5
  66. package/desktop/cmp/filter/PopoverFilterChooser.scss +0 -42
@@ -80,6 +80,7 @@ export const [SegmentedControl, segmentedControl] = hoistCmp.withFactory<Segment
80
80
  interface NormalizedOption extends SegmentedControlOption {
81
81
  label: string;
82
82
  intent?: Intent;
83
+ testId?: string;
83
84
  _key: string;
84
85
  }
85
86
 
@@ -92,13 +93,14 @@ class SegmentedControlModel extends HoistInputModel {
92
93
  return options.map((o: any, idx: number) => {
93
94
  const key = String(idx);
94
95
  if (isObject(o)) {
95
- const {label, value, icon, disabled, intent} = o as SegmentedControlOption;
96
+ const {label, value, icon, disabled, intent, testId} = o as SegmentedControlOption;
96
97
  return {
97
98
  value: this.toInternal(value),
98
99
  label: label ?? (icon ? '' : String(value)),
99
100
  icon,
100
101
  disabled,
101
102
  intent,
103
+ testId,
102
104
  _key: key
103
105
  };
104
106
  } else {
@@ -163,13 +165,15 @@ const cmp = hoistCmp.factory<SegmentedControlModel>(({model, className, ...props
163
165
  // applied via a per-button className that our SCSS keys its solid/hint coloring off of.
164
166
  const defaultIntent = intent && intent !== 'none' ? intent : null,
165
167
  bpOptions = model.normalizedOptions.map(opt => {
166
- const optIntent = opt.intent ?? defaultIntent;
168
+ const optIntent = opt.intent ?? defaultIntent,
169
+ optTestId = opt.testId ?? (testId ? `${testId}-${String(opt.value)}` : null);
167
170
  return {
168
171
  value: opt._key,
169
172
  label: opt.label,
170
173
  icon: opt.icon,
171
174
  disabled: opt.disabled,
172
- className: optIntent ? `xh-segmented-control-option--${optIntent}` : null
175
+ className: optIntent ? `xh-segmented-control-option--${optIntent}` : null,
176
+ ...(optTestId ? {[TEST_ID]: optTestId} : null)
173
177
  };
174
178
  });
175
179
 
@@ -31,7 +31,7 @@ import {debouncePromise, wait} from '@xh/hoist/promise';
31
31
  import {elemWithin, getTestId, mergeDeep, TEST_ID, throwIf, withDefault} from '@xh/hoist/utils/js';
32
32
  import {createObservableRef, getLayoutProps} from '@xh/hoist/utils/react';
33
33
  import classNames from 'classnames';
34
- import {castArray, escapeRegExp, isEmpty, isEqual, isNil, isPlainObject, keyBy} from 'lodash';
34
+ import {castArray, escapeRegExp, isEmpty, isEqual, isNil, isPlainObject, unionWith} from 'lodash';
35
35
  import {ReactElement, ReactNode} from 'react';
36
36
  import {components} from 'react-select';
37
37
  import {calcWindowedMenuWidth} from './impl/CalcWindowedMenuWidth';
@@ -191,6 +191,15 @@ export interface SelectProps extends HoistProps, HoistInputProps, LayoutProps {
191
191
 
192
192
  /** Field on provided options for sourcing each option's value (default `value`). */
193
193
  valueField?: string;
194
+
195
+ /**
196
+ * Function to generate a `SelectOption` for a (non-null) selected value not present in the
197
+ * current options list. Return null to fall back to the default value-as-label behavior.
198
+ *
199
+ * Useful with queryFn-based selects, readonly forms, or any case where options may not be
200
+ * loaded when a value is set, ensuring the value renders with its proper label.
201
+ */
202
+ generateOptionFn?: (value: any) => SelectOption;
194
203
  }
195
204
 
196
205
  /**
@@ -437,7 +446,8 @@ class SelectInputModel extends HoistInputModel {
437
446
 
438
447
  // Convert external value into option object(s). Options created if missing - this takes the
439
448
  // external value from the model, and we will respect that even if we don't know about it.
440
- // (Exception for a null value, which we will only accept if explicitly present in options.)
449
+ // (Exception for a null value, which is never synthesized - accepted only if provided via
450
+ // options.)
441
451
  override toInternal(external) {
442
452
  if (this.multiMode) {
443
453
  if (external == null || isEqual(external, this.emptyValue)) external = []; // avoid [null]
@@ -457,7 +467,10 @@ class SelectInputModel extends HoistInputModel {
457
467
  }
458
468
  }
459
469
 
460
- return createIfNotFound ? this.valueToOption(value) : null;
470
+ if (!createIfNotFound) return null;
471
+
472
+ // Value not among options - let the app generate an option for it, else synthesize one.
473
+ return this.componentProps.generateOptionFn?.(value) ?? this.valueToOption(value);
461
474
  }
462
475
 
463
476
  override toExternal(internal) {
@@ -481,11 +494,11 @@ class SelectInputModel extends HoistInputModel {
481
494
  // Normalize / clone a single source value into a normalized option object. Supports Strings
482
495
  // and Objects. Objects are validated/defaulted to ensure a label+value or label+options sublist,
483
496
  // with other fields brought along to support Selects emitting value objects with ad hoc properties.
484
- private toOption(src, depth) {
497
+ private toOption(src, depth): SelectOption {
485
498
  return isPlainObject(src) ? this.objectToOption(src, depth) : this.valueToOption(src);
486
499
  }
487
500
 
488
- private objectToOption(src, depth) {
501
+ private objectToOption(src, depth): SelectOption {
489
502
  const {componentProps} = this,
490
503
  labelField = withDefault(componentProps.labelField, 'label'),
491
504
  valueField = withDefault(componentProps.valueField, 'value');
@@ -508,7 +521,7 @@ class SelectInputModel extends HoistInputModel {
508
521
  };
509
522
  }
510
523
 
511
- private valueToOption(src) {
524
+ private valueToOption(src): SelectOption {
512
525
  return {label: src != null ? src.toString() : '-null-', value: src};
513
526
  }
514
527
 
@@ -521,13 +534,9 @@ class SelectInputModel extends HoistInputModel {
521
534
 
522
535
  // Carry forward and add to any existing internalOpts to allow our value
523
536
  // converters to continue all selected values in multiMode.
524
- const matchesByVal = keyBy(matchOpts, 'value'),
525
- newOpts = [...matchOpts];
526
- this.internalOptions.forEach(currOpt => {
527
- const matchOpt = matchesByVal[currOpt.value];
528
- if (!matchOpt) newOpts.push(currOpt); // avoiding dupes
529
- });
530
- this.internalOptions = newOpts;
537
+ this.internalOptions = unionWith(matchOpts, this.internalOptions, (a, b) =>
538
+ isEqual(a.value, b.value)
539
+ );
531
540
 
532
541
  // But only return the matching options back to the combo.
533
542
  return matchOpts;
@@ -544,8 +553,7 @@ class SelectInputModel extends HoistInputModel {
544
553
  // Option Rendering
545
554
  //----------------------
546
555
  formatOptionLabel = (opt, params) => {
547
- // Always display the standard label string in the value container (context == 'value').
548
- // If we need to expose customization here, we could consider a dedicated prop.
556
+ // Display the standard label string in the value container (context == 'value').
549
557
  if (params.context !== 'menu') {
550
558
  return opt.label;
551
559
  }
@@ -561,7 +569,7 @@ class SelectInputModel extends HoistInputModel {
561
569
  return div(opt.label);
562
570
  }
563
571
 
564
- return castArray(this.externalValue).includes(opt.value)
572
+ return castArray(this.externalValue).some(v => isEqual(v, opt.value))
565
573
  ? hbox({
566
574
  items: [
567
575
  div({
package/docs/routing.md CHANGED
@@ -150,10 +150,12 @@ numeric-only parameters). See the
150
150
  ### Conditional Route Exclusion
151
151
 
152
152
  Routes support an `omit` property (a Hoist extension, not part of Router5) that allows
153
- declarative exclusion of routes at registration time. This is useful for role-gated sections:
153
+ declarative exclusion of routes at registration time. This is useful for role-gated sections.
154
+ `getRoutes()` returns `HoistRoute[]` - Hoist's `Route` type extended with the optional `omit` key -
155
+ so the property type-checks without any cast:
154
156
 
155
157
  ```typescript
156
- override getRoutes() {
158
+ override getRoutes(): HoistRoute[] {
157
159
  return [{
158
160
  name: 'default',
159
161
  path: '/app',
@@ -221,7 +221,21 @@ grep -rn "fileChooser\|FileChooser" client-app/src/
221
221
  | `minSize` (prop) | `FileChooserModel` config `minFileSize` |
222
222
  | `enableMulti` / `enableAddMulti` (props) | `FileChooserModel` config `maxFiles` (set `1` for single-file; omit for unlimited) |
223
223
  | `showFileGrid` (prop) | Removed - default display is a grid; customize via the `fileDisplay` content prop |
224
- | `targetText` (prop) | `FileChooserModel` config `emptyDisplayPrompt` (+ `emptyDisplayHint`), or replace via the `emptyDisplay` content prop |
224
+ | `targetText` (prop) | **Preferred:** `FileChooserModel` config `emptyDisplayPrompt` (+ `emptyDisplayHint`). Use the `emptyDisplay` content prop only for a fully custom layout. |
225
+
226
+ > **`emptyDisplay` and `fileDisplay` switch on file *presence*; v85's `targetText` did not.**
227
+ > In v85 the drop target that rendered `targetText` was **always visible** - even with files
228
+ > selected - and the file grid (if enabled) sat alongside it. v86 has no always-visible text slot:
229
+ > `emptyDisplay` renders **only when no files are selected**, and `fileDisplay` (defaulting to the
230
+ > grid/card) renders **only when files are present**. There is therefore no mechanical mapping for a
231
+ > `targetText` - you must decide which presence-state its content belonged to:
232
+ > - A drop prompt ("Drag and drop files here") is empty-state content - set it as
233
+ > `emptyDisplayPrompt` (a plain string, preferred - keeps Hoist's icon, styling, and the
234
+ > auto-generated constraints hint). When files exist, the grid/`fileDisplay` takes over.
235
+ > - A message that only makes sense once a file is chosen (e.g. "Ready to upload") belongs in
236
+ > `fileDisplay`, which replaces the default grid - so set it only if you don't need that grid.
237
+ > - If your `targetText` varied on an **app flag** rather than on file presence, preserve that flag
238
+ > *inside* the slot you choose - do not let the empty/file switch silently stand in for it.
225
239
 
226
240
  Before (v85 - config as component props, model takes no args):
227
241
  ```typescript
@@ -283,17 +297,68 @@ constructor() {
283
297
  }
284
298
  ```
285
299
 
286
- **If your `targetText` was a React element** (not a plain string) - e.g. a `placeholder(...)` or a
287
- custom layout - move it to the `emptyDisplay` content prop rather than `emptyDisplayPrompt`.
288
- `emptyDisplayPrompt` accepts a `ReactNode` and is fine for a styled message, but `emptyDisplay`
289
- replaces the entire empty-state content, which is the closer match for a fully custom `targetText`:
300
+ **Prefer the styled text prompt over rebuilding the UI.** Most `targetText` values were a string or
301
+ a simple `placeholder(Icon.upload(), '...')` - which is exactly what the default empty display
302
+ already renders. Map these to `emptyDisplayPrompt` (a plain string on the model), optionally with
303
+ `emptyDisplayHint`: you keep Hoist's upload icon, styling, and the auto-generated summary of accepted
304
+ types and size/count limits. This holds even when `targetText` was a `placeholder(...)` element -
305
+ collapse it back to the prompt string rather than reaching for a content prop.
290
306
 
291
307
  ```typescript
292
- // Before (v85)
308
+ // Before (v85) - a plain-text or simple-placeholder targetText
309
+ fileChooser({model, targetText: 'Drop loan docs here'})
293
310
  fileChooser({model, targetText: placeholder(Icon.upload(), 'Drop loan docs here')})
294
311
 
295
- // After (v86)
296
- fileChooser({model, emptyDisplay: placeholder(Icon.upload(), 'Drop loan docs here')})
312
+ // After (v86) - map targetText to a styled prompt on the model; icon, styling, and the constraints
313
+ // hint come for free, and the component render needs no change
314
+ new FileChooserModel({emptyDisplayPrompt: 'Drop loan docs here'});
315
+ ```
316
+
317
+ Reserve the `emptyDisplay` / `fileDisplay` content props for layouts the prompt/hint genuinely can't
318
+ express. Note they are **not** interchangeable targets for a `targetText`: `emptyDisplay` renders
319
+ only when empty and `fileDisplay` only when files are present, so content meant for the
320
+ file-populated state must go to `fileDisplay` - never `emptyDisplay`.
321
+
322
+ **If your `targetText` was conditional** - a ternary or computed expression - work out what the
323
+ condition actually tracked before migrating, because v86 already switches content on file presence.
324
+
325
+ In the common case the condition tracked *file presence* (a flag toggled as files are added and
326
+ removed). v86's built-in switch replaces it directly: the no-file prompt becomes `emptyDisplayPrompt`
327
+ and the flag is dropped. The plain drop text needs no content prop at all - only the custom "ready"
328
+ affirmation, meant for the file-populated state, uses `fileDisplay` (which overrides the default
329
+ grid). `enableMulti` moves to `maxFiles` on the model:
330
+
331
+ ```typescript
332
+ // Before (v85) - targetText always visible, toggled by a presence-derived flag
333
+ fileChooser({
334
+ model,
335
+ enableMulti: !singleDoc,
336
+ targetText: hasFile
337
+ ? placeholder(Icon.check({intent: 'success'}), 'Ready to upload')
338
+ : placeholder(Icon.upload(), 'Drag and drop files here')
339
+ })
340
+
341
+ // After (v86) - the presence switch is built in, so the flag is no longer needed
342
+ this.chooserModel = new FileChooserModel({
343
+ maxFiles: singleDoc ? 1 : null,
344
+ emptyDisplayPrompt: 'Drag and drop files here'
345
+ });
346
+ fileChooser({
347
+ model: this.chooserModel,
348
+ fileDisplay: placeholder(Icon.check({intent: 'success'}), 'Ready to upload')
349
+ })
350
+ ```
351
+
352
+ The trap is a condition that is **independent of file presence** (e.g. an "uploads open" flag). Its
353
+ branches are *not* an empty/file pair, so splitting them across `emptyDisplay` / `fileDisplay` would
354
+ drop the condition and silently re-key the content onto presence. Keep such a flag *inside* the slot
355
+ its content belongs to - here, still on the styled prompt:
356
+
357
+ ```typescript
358
+ // independent flag - preserved on the styled prompt (model config), not mapped onto presence
359
+ new FileChooserModel({
360
+ emptyDisplayPrompt: acceptingUploads ? 'Drag and drop files here' : 'Uploads are closed'
361
+ });
297
362
  ```
298
363
 
299
364
  ### 4. Remove Deleted Mobile `DateInput` Props
@@ -8,5 +8,6 @@ import {elementFactory} from '@xh/hoist/core';
8
8
  import Dropzone from 'react-dropzone';
9
9
 
10
10
  export {Dropzone};
11
+ export {ErrorCode} from 'react-dropzone';
11
12
  export type {Accept, FileRejection, FileWithPath} from 'react-dropzone';
12
13
  export const dropzone = elementFactory(Dropzone);
package/mcp/README.md CHANGED
@@ -725,6 +725,18 @@ The `resolveDocPath()` utility in `util/paths.ts` validates that resolved paths
725
725
  repository root. It rejects paths containing `..` segments. Always use this function when resolving
726
726
  file paths from external input.
727
727
 
728
+ ### Path Separators (Cross-Platform)
729
+
730
+ ts-morph's `SourceFile.getFilePath()` always returns **forward-slash** paths on every platform
731
+ (e.g. `D:/hoist-react/cmp/grid/GridModel.ts` on Windows), whereas `resolveRepoRoot()` returns a
732
+ native path from Node's `path` module -- **backslash-separated** on Windows (`D:\hoist-react`).
733
+ Comparing or slicing one against the other (e.g. `filePath.startsWith(repoRoot + '/')`) silently
734
+ fails on Windows, filtering out every source file and yielding an empty symbol index. When
735
+ comparing against or slicing a ts-morph path, use `resolveRepoRootPosix()` (and `toPosixPath()` for
736
+ any incoming file-path argument) from `util/paths.ts` rather than `resolveRepoRoot()`. Filesystem
737
+ access that stays within Node's `path`/`fs` APIs (e.g. the doc registry, the index cache) can keep
738
+ using `resolveRepoRoot()`, since those are separator-consistent on both sides.
739
+
728
740
  ### Registry Sync
729
741
 
730
742
  The doc registry is hardcoded, not filesystem-scanned. When documentation files are added or
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Test harness for the TypeScript symbol registry's cross-platform path handling.
3
+ * Run with:
4
+ * npx tsx mcp/data/ts-registry.spec.ts
5
+ *
6
+ * The hoist-react repo has no general test framework configured, so this script
7
+ * is a self-contained, exit-coded driver. It guards the path normalization the
8
+ * symbol index depends on: ts-morph's `getFilePath()` reports forward-slash paths
9
+ * on every platform, while Node's `path` reports backslashes on Windows. The two
10
+ * must be reconciled (to POSIX form) before comparison - a regression silently
11
+ * filters out every source file and empties the entire symbol index on Windows.
12
+ * See the "Path Separators (Cross-Platform)" pitfall in mcp/README.md.
13
+ *
14
+ * Two layers:
15
+ * - Synthetic path-helper cases (`toPosixPath`, `resolveRepoRootPosix`,
16
+ * `toRelativePath`, and the exact index-guard comparison) - these simulate a
17
+ * Windows path explicitly, so they are meaningful on any host OS, including
18
+ * Linux/macOS CI.
19
+ * - Live index integration - exercises `searchSymbols` / `getSymbolDetail` /
20
+ * `getMembers` against the real hoist-react sources to prove symbols resolve
21
+ * end-to-end (and that repo-relative `--file` disambiguation works).
22
+ *
23
+ * Run after every change to ts-registry.ts, index-cache.ts, or util/paths.ts.
24
+ */
25
+ import {resolveRepoRoot, resolveRepoRootPosix, toPosixPath} from '../util/paths.js';
26
+ import {toRelativePath} from '../formatters/typescript.js';
27
+ import {searchSymbols, getSymbolDetail, getMembers} from './ts-registry.js';
28
+
29
+ // Suppress info logs during test run; we only want test output. (Warns still print.)
30
+ process.env.HOIST_MCP_QUIET = '1';
31
+
32
+ //---------------------------------------------------------------------
33
+ // Minimal assertion helpers (match the exit-coded style of the sibling
34
+ // doc-id-resolver.spec.ts).
35
+ //---------------------------------------------------------------------
36
+
37
+ let passed = 0;
38
+ let failed = 0;
39
+ const failures: string[] = [];
40
+
41
+ function ok(label: string, cond: boolean, detail?: string): void {
42
+ if (cond) {
43
+ passed++;
44
+ console.log(` PASS ${label}`);
45
+ } else {
46
+ failed++;
47
+ failures.push(detail ? `${label} -- ${detail}` : label);
48
+ console.log(` FAIL ${label}`);
49
+ if (detail) console.log(` ${detail}`);
50
+ }
51
+ }
52
+
53
+ function eq(label: string, actual: unknown, expected: unknown): void {
54
+ ok(
55
+ label,
56
+ actual === expected,
57
+ `got ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`
58
+ );
59
+ }
60
+
61
+ //---------------------------------------------------------------------
62
+ // Synthetic path-helper cases (host-OS-independent).
63
+ //---------------------------------------------------------------------
64
+
65
+ console.log('toPosixPath cases:');
66
+ eq(
67
+ 'converts backslashes',
68
+ toPosixPath('D:\\hoist-react\\core\\XH.ts'),
69
+ 'D:/hoist-react/core/XH.ts'
70
+ );
71
+ eq('leaves forward slashes untouched', toPosixPath('/repo/core/XH.ts'), '/repo/core/XH.ts');
72
+ eq('normalizes mixed separators', toPosixPath('a\\b/c\\d'), 'a/b/c/d');
73
+ eq('is idempotent', toPosixPath(toPosixPath('D:\\a\\b')), 'D:/a/b');
74
+
75
+ console.log('\nresolveRepoRootPosix cases:');
76
+ const rootPosix = resolveRepoRootPosix();
77
+ ok('has no backslashes', !rootPosix.includes('\\'), `got "${rootPosix}"`);
78
+ eq('equals toPosixPath(resolveRepoRoot())', rootPosix, toPosixPath(resolveRepoRoot()));
79
+
80
+ console.log('\nIndex-guard regression cases (simulated Windows paths):');
81
+ // The exact scenario that broke: on Windows, resolveRepoRoot() yields a
82
+ // backslash path while ts-morph yields forward slashes. Assert the naive guard
83
+ // fails and the POSIX-normalized guard succeeds - regardless of the host OS.
84
+ const winRootNative = 'D:\\hoist-react'; // what resolveRepoRoot() returns on Windows
85
+ const winFile = 'D:/hoist-react/cmp/grid/GridModel.ts'; // what ts-morph getFilePath() returns on Windows
86
+ ok(
87
+ 'naive backslash-root comparison fails (the original bug)',
88
+ !winFile.startsWith(winRootNative + '/')
89
+ );
90
+ ok(
91
+ 'posix-normalized root comparison succeeds (the fix)',
92
+ winFile.startsWith(toPosixPath(winRootNative) + '/')
93
+ );
94
+ eq(
95
+ 'relative slice under posix root is correct',
96
+ winFile.slice(toPosixPath(winRootNative).length + 1),
97
+ 'cmp/grid/GridModel.ts'
98
+ );
99
+
100
+ console.log('\ntoRelativePath cases:');
101
+ const sampleRel = 'cmp/grid/GridModel.ts';
102
+ eq('strips a posix absolute path', toRelativePath(`${rootPosix}/${sampleRel}`), sampleRel);
103
+ // A backslash absolute path (same root) must still strip AND normalize to posix.
104
+ const backslashAbs = `${rootPosix.replace(/\//g, '\\')}\\cmp\\grid\\GridModel.ts`;
105
+ eq('strips + normalizes a backslash absolute path', toRelativePath(backslashAbs), sampleRel);
106
+
107
+ //---------------------------------------------------------------------
108
+ // Live index integration (needs the real hoist-react sources; builds or
109
+ // loads the cached index on first query).
110
+ //---------------------------------------------------------------------
111
+
112
+ console.log('\nLive index cases:');
113
+
114
+ const results = await searchSymbols('GridModel', {limit: 5});
115
+ const gm = results.find(r => r.name === 'GridModel' && r.kind === 'class');
116
+ ok('searchSymbols("GridModel") finds the class', !!gm, `got ${results.length} results`);
117
+ if (gm) {
118
+ eq('GridModel resolves to cmp/grid/GridModel.ts', toRelativePath(gm.filePath), sampleRel);
119
+ eq('GridModel sourcePackage is cmp/grid', gm.sourcePackage, 'cmp/grid');
120
+ }
121
+
122
+ const detail = await getSymbolDetail('GridModel');
123
+ ok(
124
+ 'getSymbolDetail("GridModel") resolves',
125
+ !!detail && detail.kind === 'class',
126
+ `got ${JSON.stringify(detail && {name: detail.name, kind: detail.kind})}`
127
+ );
128
+
129
+ const members = await getMembers('GridModel');
130
+ ok(
131
+ 'getMembers("GridModel") returns members',
132
+ !!members && members.members.length > 0,
133
+ `count=${members?.members.length ?? 0}`
134
+ );
135
+
136
+ // --file disambiguation (the secondary Windows bug): a repo-relative path passed
137
+ // to findIndexEntry must resolve to the matching entry. `View` exists in both
138
+ // cmp/viewmanager and data/cube; the path selects the data/cube one.
139
+ const view = await getSymbolDetail('View', 'data/cube/View.ts');
140
+ ok(
141
+ 'getSymbolDetail("View", "data/cube/View.ts") disambiguates by repo-relative path',
142
+ !!view && toRelativePath(view.filePath) === 'data/cube/View.ts',
143
+ `relPath=${view ? toRelativePath(view.filePath) : 'null'}`
144
+ );
145
+
146
+ //---------------------------------------------------------------------
147
+ // Tally
148
+ //---------------------------------------------------------------------
149
+
150
+ console.log(`\nTotal: ${passed} passed, ${failed} failed.`);
151
+
152
+ if (failed > 0) {
153
+ console.log('\nFailure details:');
154
+ for (const f of failures) console.log(` - ${f}`);
155
+ process.exit(1);
156
+ }
@@ -24,7 +24,7 @@ import type {
24
24
  import {resolve} from 'node:path';
25
25
 
26
26
  import {log} from '../util/logger.js';
27
- import {resolveRepoRoot} from '../util/paths.js';
27
+ import {resolveRepoRoot, resolveRepoRootPosix, toPosixPath} from '../util/paths.js';
28
28
  import {computeFingerprint, loadCache, writeCache} from './index-cache.js';
29
29
 
30
30
  //------------------------------------------------------------------
@@ -295,7 +295,9 @@ function buildSymbolIndex(proj: Project): {
295
295
  } {
296
296
  const index = new Map<string, SymbolEntry[]>();
297
297
  const mIndex = new Map<string, MemberIndexEntry[]>();
298
- const repoRoot = resolveRepoRoot();
298
+ // POSIX form: compared against ts-morph `getFilePath()`, which always uses
299
+ // forward slashes even on Windows.
300
+ const repoRoot = resolveRepoRootPosix();
299
301
 
300
302
  const counts = {total: 0, exported: 0, byKind: {} as Record<string, number>};
301
303
  let memberCount = 0;
@@ -549,7 +551,7 @@ function buildSymbolIndex(proj: Project): {
549
551
  // Index Promise prototype extensions from promise/Promise.ts
550
552
  const promiseFile = proj.getSourceFile(sf => sf.getFilePath().endsWith('/promise/Promise.ts'));
551
553
  if (promiseFile) {
552
- indexPromiseExtensions(promiseFile, index, mIndex, resolveRepoRoot());
554
+ indexPromiseExtensions(promiseFile, index, mIndex, resolveRepoRootPosix());
553
555
  }
554
556
 
555
557
  // Populate `memberNames` on symbol entries for every member-indexed owner
@@ -799,7 +801,8 @@ function ensureProject(): Project {
799
801
  * JSDoc).
800
802
  */
801
803
  function enrichMemberIndexFromImplements(proj: Project): void {
802
- const repoRoot = resolveRepoRoot();
804
+ // POSIX form: compared against ts-morph `getFilePath()` (see buildSymbolIndex).
805
+ const repoRoot = resolveRepoRootPosix();
803
806
 
804
807
  for (const sourceFile of proj.getSourceFiles()) {
805
808
  const filePath = sourceFile.getFilePath();
@@ -1374,8 +1377,12 @@ function findIndexEntry(name: string, filePath?: string): SymbolEntry | null {
1374
1377
 
1375
1378
  if (filePath) {
1376
1379
  // Resolve relative paths against repo root -- search results display repo-relative
1377
- // paths, so callers will typically pass those rather than absolute paths.
1378
- const resolved = filePath.startsWith('/') ? filePath : resolve(resolveRepoRoot(), filePath);
1380
+ // paths, so callers will typically pass those rather than absolute paths. Normalize
1381
+ // to POSIX so the comparison matches entry.filePath (ts-morph forward slashes) on
1382
+ // Windows, and recognize Windows drive-letter absolutes (e.g. `D:/...`) as absolute.
1383
+ const posix = toPosixPath(filePath);
1384
+ const isAbsolute = posix.startsWith('/') || /^[a-zA-Z]:\//.test(posix);
1385
+ const resolved = isAbsolute ? posix : toPosixPath(resolve(resolveRepoRoot(), posix));
1379
1386
  return exact.find(e => e.filePath === resolved) ?? null;
1380
1387
  }
1381
1388
 
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import {z} from 'zod';
13
13
  import type {MemberInfo, MemberIndexEntry, SymbolEntry, SymbolDetail} from '../data/ts-registry.js';
14
- import {resolveRepoRoot} from '../util/paths.js';
14
+ import {resolveRepoRootPosix, toPosixPath} from '../util/paths.js';
15
15
 
16
16
  /** Remove blank lines from a JSDoc string to produce more compact output. */
17
17
  function collapseJsDoc(jsDoc: string): string {
@@ -31,8 +31,12 @@ export function truncateType(typeStr: string): string {
31
31
 
32
32
  /** Convert an absolute file path to a repo-relative path. */
33
33
  export function toRelativePath(filePath: string): string {
34
- const root = resolveRepoRoot();
35
- return filePath.startsWith(root) ? filePath.slice(root.length + 1) : filePath;
34
+ // Symbol filePaths originate from ts-morph (forward slashes on all platforms);
35
+ // compare in POSIX form so the repo-root prefix strips correctly on Windows,
36
+ // where `resolveRepoRoot()` would otherwise yield a backslash path.
37
+ const root = resolveRepoRootPosix();
38
+ const posix = toPosixPath(filePath);
39
+ return posix.startsWith(root) ? posix.slice(root.length + 1) : posix;
36
40
  }
37
41
 
38
42
  /**
package/mcp/util/paths.ts CHANGED
@@ -39,6 +39,27 @@ export function resolveRepoRoot(): string {
39
39
  return repoRoot;
40
40
  }
41
41
 
42
+ /** Convert a filesystem path to POSIX (forward-slash) form. */
43
+ export function toPosixPath(p: string): string {
44
+ return p.replace(/\\/g, '/');
45
+ }
46
+
47
+ /**
48
+ * Repo root in POSIX (forward-slash) form, matching ts-morph's `getFilePath()`
49
+ * convention, which always reports forward slashes on every platform.
50
+ *
51
+ * On Windows, {@link resolveRepoRoot} returns a backslash path (e.g.
52
+ * `D:\hoist-react`) while ts-morph reports `D:/hoist-react/...`; comparing or
53
+ * slicing the two directly fails, silently filtering out every source file and
54
+ * yielding an empty symbol index. Use this whenever comparing against or
55
+ * slicing a ts-morph source-file path (see `data/ts-registry.ts` and
56
+ * `formatters/typescript.ts`). Filesystem access that stays within Node's
57
+ * `path` APIs should keep using {@link resolveRepoRoot}.
58
+ */
59
+ export function resolveRepoRootPosix(): string {
60
+ return toPosixPath(resolveRepoRoot());
61
+ }
62
+
42
63
  /**
43
64
  * Resolve the `@xh/hoist` library version from the repo root `package.json`.
44
65
  *
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
- import {GridModel} from '@xh/hoist/cmp/grid';
7
+ import {GridModel, IColChooserModel} from '@xh/hoist/cmp/grid';
8
8
  import {HoistModel, HSide, XH} from '@xh/hoist/core';
9
9
  import '@xh/hoist/mobile/register';
10
10
  import {bindable, action, makeObservable, observable} from '@xh/hoist/mobx';
@@ -17,7 +17,7 @@ import {clone, find, sortBy} from 'lodash';
17
17
  * It is not necessary to manually create instances of this class within an application.
18
18
  * @internal
19
19
  */
20
- export class ColChooserModel extends HoistModel {
20
+ export class ColChooserModel extends HoistModel implements IColChooserModel {
21
21
  override xhImpl = true;
22
22
 
23
23
  gridModel: GridModel;
@@ -85,6 +85,7 @@ export const [SegmentedControl, segmentedControl] = hoistCmp.withFactory<Segment
85
85
  interface NormalizedOption extends SegmentedControlOption {
86
86
  label: string;
87
87
  intent?: Intent;
88
+ testId?: string;
88
89
  _key: string;
89
90
  }
90
91
 
@@ -97,13 +98,14 @@ class SegmentedControlModel extends HoistInputModel {
97
98
  return options.map((o: any, idx: number) => {
98
99
  const key = String(idx);
99
100
  if (isObject(o)) {
100
- const {label, value, icon, disabled, intent} = o as SegmentedControlOption;
101
+ const {label, value, icon, disabled, intent, testId} = o as SegmentedControlOption;
101
102
  return {
102
103
  value: this.toInternal(value),
103
104
  label: label ?? (icon ? '' : String(value)),
104
105
  icon,
105
106
  disabled,
106
107
  intent,
108
+ testId,
107
109
  _key: key
108
110
  };
109
111
  } else {
@@ -168,7 +170,8 @@ const cmp = hoistCmp.factory<SegmentedControlModel>(({model, className, ...props
168
170
 
169
171
  const buttons = model.normalizedOptions.map(opt => {
170
172
  const optIntent = opt.intent ?? defaultIntent,
171
- selected = opt._key === selectedKey;
173
+ selected = opt._key === selectedKey,
174
+ optTestId = opt.testId ?? (testId ? `${testId}-${String(opt.value)}` : null);
172
175
  // Wrap the label so it can truncate with an ellipsis when the segment is too narrow,
173
176
  // rather than hard-clipping mid-character. Pass null for icon-only options so the Button
174
177
  // renders the icon alone (an empty span would suppress that).
@@ -187,7 +190,8 @@ const cmp = hoistCmp.factory<SegmentedControlModel>(({model, className, ...props
187
190
  selected && 'xh-segmented-control-option--selected',
188
191
  optIntent && `xh-segmented-control-option--${optIntent}`
189
192
  ),
190
- onClick: () => model.onValueChange(opt._key)
193
+ onClick: () => model.onValueChange(opt._key),
194
+ testId: optTestId
191
195
  });
192
196
  });
193
197