@adecore/ui 0.38.0 → 0.40.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 (210) hide show
  1. package/dist/AccentSwatches.js +2 -3
  2. package/dist/Banner.js +1 -6
  3. package/dist/ChoiceCards.js +0 -5
  4. package/dist/ColorSwatch.js +1 -4
  5. package/dist/ColorWheelPicker.js +3 -6
  6. package/dist/DockShell.js +3 -5
  7. package/dist/EmptyState.js +0 -1
  8. package/dist/ErrorBoundary.js +1 -5
  9. package/dist/Field.js +2 -6
  10. package/dist/FileIcon.js +2 -4
  11. package/dist/Icon.js +1 -2
  12. package/dist/IconButton.js +1 -1
  13. package/dist/IconPicker.js +3 -7
  14. package/dist/Input.js +1 -2
  15. package/dist/Kbd.js +1 -3
  16. package/dist/LazyBoundary.js +2 -3
  17. package/dist/Meter.js +0 -1
  18. package/dist/MiddleTruncate.js +2 -6
  19. package/dist/PanelEmpty.js +0 -1
  20. package/dist/PanelHeader.js +1 -4
  21. package/dist/Pill.js +1 -6
  22. package/dist/ProjectSwitcher.js +1 -1
  23. package/dist/PromptDialog.js +5 -8
  24. package/dist/RelativeTime.js +1 -1
  25. package/dist/SegmentBar.js +1 -2
  26. package/dist/Segmented.js +2 -5
  27. package/dist/Select.js +1 -4
  28. package/dist/ShortcutHints.js +2 -3
  29. package/dist/Slider.js +0 -1
  30. package/dist/SlidingColumn.js +2 -4
  31. package/dist/SplitButton.js +1 -5
  32. package/dist/TextMenu.js +1 -5
  33. package/dist/Tile.js +0 -4
  34. package/dist/Toasts.js +1 -9
  35. package/dist/Tooltip.js +1 -1
  36. package/dist/UIProvider.js +0 -5
  37. package/dist/Waveform.js +4 -2
  38. package/dist/Wipe.js +1 -6
  39. package/dist/ZoomControls.js +0 -1
  40. package/dist/clipboard.js +2 -5
  41. package/dist/combobox/parts.js +3 -9
  42. package/dist/command-palette/parts.js +5 -14
  43. package/dist/dialog/parts.js +5 -10
  44. package/dist/dialog-layer.js +2 -3
  45. package/dist/error-boundary.js +4 -20
  46. package/dist/file-icon.js +4 -9
  47. package/dist/file-tree/empty-lines.js +2 -8
  48. package/dist/file-tree/use-empty-lines.js +1 -5
  49. package/dist/file-tree/use-slots.js +6 -10
  50. package/dist/format/datetime.js +32 -29
  51. package/dist/format/duration.js +2 -10
  52. package/dist/format/locale.js +19 -39
  53. package/dist/format/number.js +9 -22
  54. package/dist/format/regions.js +4 -7
  55. package/dist/icon-picker.js +5 -14
  56. package/dist/index.d.ts +1 -0
  57. package/dist/index.js +1 -0
  58. package/dist/key-value-list/parts.js +1 -5
  59. package/dist/lazy.js +4 -19
  60. package/dist/locales.js +2 -6
  61. package/dist/menu/parts.js +61 -133
  62. package/dist/middle-truncate.js +2 -3
  63. package/dist/placement.js +0 -1
  64. package/dist/popover/parts.js +1 -5
  65. package/dist/popup-layer.js +2 -4
  66. package/dist/prefetch.js +2 -5
  67. package/dist/selection.js +1 -5
  68. package/dist/settings/MasterDetail.js +1 -7
  69. package/dist/settings/SettingsDialog.js +1 -2
  70. package/dist/settings/SettingsRow.js +2 -5
  71. package/dist/settings/SettingsSection.js +1 -1
  72. package/dist/shortcut-hints.js +1 -5
  73. package/dist/shortcut.js +1 -2
  74. package/dist/tabs/overflow.js +1 -6
  75. package/dist/tabs/parts.js +69 -95
  76. package/dist/testing/dedupe.js +5 -7
  77. package/dist/tint.d.ts +2 -0
  78. package/dist/tint.js +11 -0
  79. package/dist/toast-store.js +2 -10
  80. package/dist/tree/use-tree-shift.js +2 -6
  81. package/dist/useColumnResize.js +9 -10
  82. package/dist/useNow.js +2 -9
  83. package/dist/workspace/DropPreview.js +1 -5
  84. package/dist/workspace/preview.js +4 -9
  85. package/package.json +9 -9
  86. package/src/AccentSwatches.tsx +9 -3
  87. package/src/Banner.tsx +5 -11
  88. package/src/Button.tsx +4 -0
  89. package/src/Checkbox.tsx +3 -0
  90. package/src/ChoiceCards.tsx +12 -11
  91. package/src/CloseButton.tsx +1 -0
  92. package/src/ColorSwatch.tsx +7 -8
  93. package/src/ColorWheelPicker.tsx +28 -12
  94. package/src/ColumnResizeHandle.tsx +1 -0
  95. package/src/CopyButton.tsx +6 -0
  96. package/src/DisabledReason.tsx +1 -0
  97. package/src/DockShell.tsx +11 -10
  98. package/src/EmptyState.tsx +10 -6
  99. package/src/ErrorBoundary.tsx +13 -10
  100. package/src/Field.tsx +11 -13
  101. package/src/FileIcon.tsx +4 -4
  102. package/src/Icon.tsx +2 -4
  103. package/src/IconButton.tsx +14 -5
  104. package/src/IconPicker.tsx +30 -15
  105. package/src/Input.tsx +11 -9
  106. package/src/Kbd.tsx +8 -7
  107. package/src/LazyBoundary.tsx +3 -3
  108. package/src/Meter.tsx +7 -2
  109. package/src/MiddleTruncate.tsx +15 -6
  110. package/src/PanelEmpty.tsx +9 -7
  111. package/src/PanelHeader.tsx +2 -5
  112. package/src/Pill.tsx +7 -8
  113. package/src/ProjectSwitcher.tsx +16 -6
  114. package/src/PromptDialog.tsx +31 -26
  115. package/src/RelativeTime.tsx +6 -1
  116. package/src/SegmentBar.tsx +22 -11
  117. package/src/Segmented.tsx +16 -11
  118. package/src/Select.tsx +7 -4
  119. package/src/SelectSearch.tsx +3 -0
  120. package/src/ShortcutHints.tsx +24 -3
  121. package/src/Skeleton.tsx +1 -0
  122. package/src/Slider.tsx +4 -1
  123. package/src/SlidingColumn.tsx +18 -14
  124. package/src/Spinner.tsx +1 -0
  125. package/src/SplitButton.tsx +8 -8
  126. package/src/Stepper.tsx +6 -0
  127. package/src/Switch.tsx +2 -0
  128. package/src/TextMenu.tsx +2 -5
  129. package/src/Tile.tsx +2 -6
  130. package/src/Toasts.tsx +8 -12
  131. package/src/Tooltip.tsx +10 -5
  132. package/src/UIProvider.tsx +7 -7
  133. package/src/Waveform.tsx +41 -12
  134. package/src/Wipe.tsx +5 -6
  135. package/src/ZoomControls.tsx +5 -2
  136. package/src/clipboard.ts +4 -5
  137. package/src/combobox/parts.tsx +18 -14
  138. package/src/command-palette/context.ts +2 -0
  139. package/src/command-palette/index.parts.ts +1 -0
  140. package/src/command-palette/parts.tsx +20 -14
  141. package/src/command-palette/virtual.tsx +21 -0
  142. package/src/dialog/parts.tsx +10 -13
  143. package/src/dialog-layer.ts +5 -3
  144. package/src/error-boundary.ts +7 -20
  145. package/src/file-icon.ts +8 -9
  146. package/src/file-tree/empty-lines.ts +24 -8
  147. package/src/file-tree/helpers.ts +34 -0
  148. package/src/file-tree/highlight.ts +19 -0
  149. package/src/file-tree/index.parts.ts +1 -0
  150. package/src/file-tree/parts.tsx +41 -5
  151. package/src/file-tree/use-empty-lines.ts +22 -5
  152. package/src/file-tree/use-file-tree-shift.tsx +10 -0
  153. package/src/file-tree/use-file-tree.ts +2 -0
  154. package/src/file-tree/use-slots.ts +77 -12
  155. package/src/format/datetime.ts +42 -17
  156. package/src/format/duration.ts +28 -10
  157. package/src/format/index.ts +3 -0
  158. package/src/format/locale.ts +33 -42
  159. package/src/format/number.ts +21 -27
  160. package/src/format/regions.ts +4 -7
  161. package/src/icon-picker.ts +23 -16
  162. package/src/index.ts +9 -0
  163. package/src/key-value-list/parts.tsx +1 -5
  164. package/src/lazy-landings.ts +2 -0
  165. package/src/lazy.tsx +23 -19
  166. package/src/locales.ts +2 -6
  167. package/src/match.ts +14 -0
  168. package/src/menu/parts.tsx +30 -55
  169. package/src/middle-truncate.ts +12 -3
  170. package/src/modality.ts +4 -0
  171. package/src/placement.ts +2 -1
  172. package/src/platform.ts +2 -0
  173. package/src/popover/parts.tsx +3 -5
  174. package/src/popup-layer.ts +6 -4
  175. package/src/prefetch.ts +18 -5
  176. package/src/project-switcher-row.tsx +2 -0
  177. package/src/segmented-keys.ts +6 -0
  178. package/src/select-shared.ts +2 -0
  179. package/src/selection.ts +5 -5
  180. package/src/settings/ConfirmDialog.tsx +3 -0
  181. package/src/settings/MasterDetail.tsx +3 -9
  182. package/src/settings/SettingsDialog.tsx +27 -9
  183. package/src/settings/SettingsRow.tsx +16 -10
  184. package/src/settings/SettingsSection.tsx +10 -5
  185. package/src/settings/index.ts +3 -0
  186. package/src/settings/target.ts +1 -0
  187. package/src/shortcut-hints.ts +10 -5
  188. package/src/shortcut.ts +26 -6
  189. package/src/tabs/overflow.ts +8 -6
  190. package/src/tabs/parts.tsx +50 -26
  191. package/src/testing/dedupe.ts +27 -7
  192. package/src/testing/fake-source.ts +1 -0
  193. package/src/tint.ts +13 -0
  194. package/src/toast-store.ts +20 -14
  195. package/src/tree/parts.tsx +8 -0
  196. package/src/tree/shift.ts +6 -0
  197. package/src/tree/use-tree-shift.tsx +23 -7
  198. package/src/useAsyncAction.ts +8 -0
  199. package/src/useColumnResize.ts +24 -15
  200. package/src/useContentSize.ts +5 -0
  201. package/src/useMeasuredWidth.ts +1 -0
  202. package/src/useNow.ts +6 -11
  203. package/src/workspace/DropPreview.tsx +3 -5
  204. package/src/workspace/SplitView.tsx +109 -0
  205. package/src/workspace/TabStrip.tsx +42 -0
  206. package/src/workspace/Workspace.tsx +5 -0
  207. package/src/workspace/context.ts +1 -0
  208. package/src/workspace/geometry.ts +49 -0
  209. package/src/workspace/model.ts +72 -0
  210. package/src/workspace/preview.ts +15 -15
@@ -11,12 +11,15 @@ export function matchHighlight(): Highlight | null {
11
11
  if (typeof CSS === 'undefined' || !('highlights' in CSS) || typeof Highlight === 'undefined') {
12
12
  return null;
13
13
  }
14
+
14
15
  if (shared === null) {
15
16
  shared = new Highlight();
16
17
  }
18
+
17
19
  if (CSS.highlights.get(MATCH_HIGHLIGHT) !== shared) {
18
20
  CSS.highlights.set(MATCH_HIGHLIGHT, shared);
19
21
  }
22
+
20
23
  return shared;
21
24
  }
22
25
 
@@ -24,51 +27,66 @@ export function matchHighlight(): Highlight | null {
24
27
  export function matchRanges(text: string, query: string): [number, number][] {
25
28
  const wanted = query.toLowerCase();
26
29
  const haystack = text.toLowerCase();
30
+
27
31
  // A letter that lowercases to more than one would shift every offset after it.
28
32
  if (wanted === '' || haystack.length !== text.length) {
29
33
  return [];
30
34
  }
35
+
31
36
  const found: [number, number][] = [];
32
37
  let at = haystack.indexOf(wanted);
38
+
33
39
  while (at !== -1) {
34
40
  found.push([at, at + wanted.length]);
35
41
  at = haystack.indexOf(wanted, at + wanted.length);
36
42
  }
43
+
37
44
  return found;
38
45
  }
39
46
 
40
47
  /* The ranges over the visible text of each row's name where `query` occurs. */
41
48
  export function rangesOfMatches(rows: readonly HTMLElement[], query: string): Range[] {
42
49
  const ranges: Range[] = [];
50
+
43
51
  if (query === '') {
44
52
  return ranges;
45
53
  }
54
+
46
55
  for (const row of rows) {
47
56
  const content = row.querySelector('[data-item-section="content"]');
57
+
48
58
  if (content === null) {
49
59
  continue;
50
60
  }
61
+
51
62
  const nodes: Text[] = [];
52
63
  const walker = row.ownerDocument.createTreeWalker(content, NodeFilter.SHOW_TEXT);
64
+
53
65
  for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
54
66
  if (node.parentElement?.closest(HIDDEN_PARTS) === null) {
55
67
  nodes.push(node as Text);
56
68
  }
57
69
  }
70
+
58
71
  const starts: number[] = [];
59
72
  let text = '';
73
+
60
74
  for (const node of nodes) {
61
75
  starts.push(text.length);
62
76
  text += node.data;
63
77
  }
78
+
64
79
  // The node an offset falls in, and the offset inside it; an end may sit at the end of a node.
65
80
  const locate = (offset: number, end: boolean): [Text, number] => {
66
81
  let index = nodes.length - 1;
82
+
67
83
  while (index > 0 && (end ? starts[index]! >= offset : starts[index]! > offset)) {
68
84
  index--;
69
85
  }
86
+
70
87
  return [nodes[index]!, offset - starts[index]!];
71
88
  };
89
+
72
90
  for (const [start, end] of matchRanges(text, query)) {
73
91
  const range = row.ownerDocument.createRange();
74
92
  range.setStart(...locate(start, false));
@@ -76,5 +94,6 @@ export function rangesOfMatches(rows: readonly HTMLElement[], query: string): Ra
76
94
  ranges.push(range);
77
95
  }
78
96
  }
97
+
79
98
  return ranges;
80
99
  }
@@ -1,5 +1,6 @@
1
1
  export { FileTreeRoot as Root } from './parts.tsx';
2
2
  export { TreeCheckbox as Checkbox, TreeControl as Control, TreeDecoration as Decoration } from '../tree/parts.tsx';
3
+
3
4
  export {
4
5
  ancestorDirsOf,
5
6
  applyExpansion,
@@ -27,16 +27,20 @@ export type FileTreeRootProps = useRender.ComponentProps<'div'> & {
27
27
  onExpandedPathsChange?(paths: readonly string[]): void;
28
28
  renderControl?(row: FileTreeVisibleRow): ReactNode;
29
29
  renderDecoration?(row: FileTreeVisibleRow): ReactNode;
30
- /* `after-status` draws the decoration after the engine's git status column instead of between the name and that column. */
31
30
  decorationOrder?: FileTreeDecorationOrder;
32
- /* A Lucide icon in place of the row's own glyph (the file icon, or a directory's chevron); undefined keeps that glyph. */
31
+
32
+ /* In place of the row's own glyph (the file icon, or a directory's chevron); undefined keeps that glyph. */
33
33
  rowIcon?(row: FileTreeVisibleRow): LucideIcon | undefined;
34
- /* As tall as its rows, up to the height its container leaves it, instead of filling that container. */
34
+
35
+ /* As tall as its rows, up to the height its container leaves it. */
35
36
  fitContent?: boolean;
36
- /* Marks where this occurs in every name, ignoring case, such as the query a list was filtered by. */
37
+
38
+ /* Marks where this occurs in every name, ignoring case. */
37
39
  highlight?: string;
38
- /* The line under an open directory with nothing in it; undefined draws none, such as while its children still load. */
40
+
41
+ /* The line under an open, empty directory; undefined draws none, such as while its children still load. */
39
42
  emptyLabel?: FileTreeEmptyLabel;
43
+
40
44
  treeProps?: Omit<PierreProps, 'model'>;
41
45
  };
42
46
 
@@ -75,20 +79,26 @@ export function FileTreeRoot({
75
79
  const { t } = useTranslation('ui');
76
80
  const [frame, setFrame] = useState<HTMLDivElement | null>(null);
77
81
  const { attach, bar } = useFileTreeShift(model, resetKey);
82
+
78
83
  const frameRef = (node: Element | null) => {
79
84
  setFrame(node as HTMLDivElement | null);
80
85
  attach(node as HTMLElement | null);
81
86
  mergeRefs<Element>(ref)(node);
82
87
  };
88
+
83
89
  const subscribeToRows = (listener: () => void): (() => void) => (fitContent ? model.subscribe(listener) : () => {});
90
+
84
91
  const countRows = (): number => model.getVisibleCount();
92
+
85
93
  const rowCount = useSyncExternalStore(subscribeToRows, countRows, countRows);
86
94
  const slots = useFileTreeSlots(model, frame, label ?? t('tree.label'), { order: decorationOrder, rowIcon, highlight, emptyLabel });
87
95
  const passEmptyLine = useEmptyLines(model, frame, emptyLabel);
88
96
  const latestLoads = useRef({ onLoadChildren, onExpandedPathsChange });
97
+
89
98
  useLayoutEffect(() => {
90
99
  latestLoads.current = { onLoadChildren, onExpandedPathsChange };
91
100
  }, [onLoadChildren, onExpandedPathsChange]);
101
+
92
102
  const cancelFocus = useRef<(() => void) | undefined>(undefined);
93
103
 
94
104
  useEffect(() => () => cancelFocus.current?.(), [model]);
@@ -97,37 +107,47 @@ export function FileTreeRoot({
97
107
  let previous = new Set<string>();
98
108
  let disposed = false;
99
109
  let pending = false;
110
+
100
111
  const report = (): void => {
101
112
  pending = false;
113
+
102
114
  if (disposed) {
103
115
  return;
104
116
  }
117
+
105
118
  const rows = visibleRows(model).filter((row) => row.kind === 'directory');
106
119
  const known = new Set(rows.map((row) => row.path));
107
120
  const expanded = new Set([...previous].filter((path) => !known.has(path)));
121
+
108
122
  for (const row of rows) {
109
123
  if (row.isExpanded) {
110
124
  expanded.add(row.path);
111
125
  }
112
126
  }
127
+
113
128
  const opened = [...expanded].filter((path) => !previous.has(path));
114
129
  const changed = opened.length > 0 || [...previous].some((path) => !expanded.has(path));
115
130
  previous = expanded;
131
+
116
132
  if (changed) {
117
133
  latestLoads.current.onExpandedPathsChange?.([...expanded]);
118
134
  }
135
+
119
136
  for (const path of opened) {
120
137
  latestLoads.current.onLoadChildren?.(path);
121
138
  }
122
139
  };
140
+
123
141
  const schedule = (): void => {
124
142
  if (!pending) {
125
143
  pending = true;
126
144
  queueMicrotask(report);
127
145
  }
128
146
  };
147
+
129
148
  const unsubscribe = model.subscribe(schedule);
130
149
  report();
150
+
131
151
  return () => {
132
152
  disposed = true;
133
153
  unsubscribe();
@@ -138,33 +158,43 @@ export function FileTreeRoot({
138
158
  if (frame === null) {
139
159
  return;
140
160
  }
161
+
141
162
  const onClickRow = (event: MouseEvent): void => {
142
163
  if (event.defaultPrevented || inSlot(event) || extendsSelection(event)) {
143
164
  return;
144
165
  }
166
+
145
167
  const path = rowPathOf({ nativeEvent: event });
168
+
146
169
  if (path !== null && directoryHandle(model, path) === null) {
147
170
  onActivate?.(path);
148
171
  }
149
172
  };
173
+
150
174
  const onKey = (event: KeyboardEvent): void => {
151
175
  if (event.defaultPrevented || inSlot(event)) {
152
176
  return;
153
177
  }
178
+
154
179
  if (movesFocus(event) && selectionFollowsFocus) {
155
180
  cancelFocus.current?.();
156
181
  // beta.6 stops the key in its own handler; selection must follow after that handler moves focus.
157
182
  cancelFocus.current = followFocus(model, onFocusMove);
158
183
  }
184
+
159
185
  passEmptyLine(event);
186
+
160
187
  if (event.key === 'Enter' && !event.altKey && !event.metaKey && !event.ctrlKey) {
161
188
  const path = model.getFocusedPath();
189
+
162
190
  if (path === null) {
163
191
  return;
164
192
  }
193
+
165
194
  event.preventDefault();
166
195
  event.stopPropagation();
167
196
  const directory = directoryHandle(model, path);
197
+
168
198
  if (directory) {
169
199
  directory.toggle();
170
200
  } else {
@@ -172,23 +202,28 @@ export function FileTreeRoot({
172
202
  }
173
203
  }
174
204
  };
205
+
175
206
  const withTargets =
176
207
  <RowEvent extends Event>(handle?: (path: string, targets: readonly string[], event: RowEvent) => void) =>
177
208
  (event: RowEvent): void => {
178
209
  if (inSlot(event)) {
179
210
  return;
180
211
  }
212
+
181
213
  const path = rowPathOf({ nativeEvent: event });
214
+
182
215
  if (path !== null) {
183
216
  handle?.(path, menuTargetsOf(path, model.getSelectedPaths()), event);
184
217
  }
185
218
  };
219
+
186
220
  const onContext = withTargets(onRowContextMenu);
187
221
  const onDrag = withTargets(onRowDragStart);
188
222
  frame.addEventListener('click', onClickRow);
189
223
  frame.addEventListener('keydown', onKey, true);
190
224
  frame.addEventListener('contextmenu', onContext);
191
225
  frame.addEventListener('dragstart', onDrag);
226
+
192
227
  return () => {
193
228
  frame.removeEventListener('click', onClickRow);
194
229
  frame.removeEventListener('keydown', onKey, true);
@@ -212,6 +247,7 @@ export function FileTreeRoot({
212
247
  <PierreFileTree {...treeProps} model={model} className={clsx('adecore-file-tree-host', treeProps?.className)} />
213
248
  {slots.map(({ row, control, decoration, icon }) => {
214
249
  const ownIcon = icon === null ? undefined : rowIcon?.(row);
250
+
215
251
  return (
216
252
  <span key={control.slot} style={{ display: 'contents' }}>
217
253
  {createPortal(renderControl?.(row), control)}
@@ -9,11 +9,7 @@ function onEmptyLine(event: Event): boolean {
9
9
  return event.composedPath().some((node) => node instanceof HTMLElement && node.dataset.emptyLine === 'true');
10
10
  }
11
11
 
12
- /*
13
- * Keeps a line under every open directory that is known to be empty, and keeps the line out of
14
- * reach: a pointer on it does nothing and a key passes it. Returns what to call with every key the
15
- * tree hears, after anything that notes where focus was.
16
- */
12
+ /* A pointer on a line does nothing and a key passes it. Call what it returns with every key the tree hears, after anything that notes where focus was. */
17
13
  export function useEmptyLines(model: FileTree, frame: HTMLElement | null, emptyLabel: FileTreeEmptyLabel | undefined): (event: KeyboardEvent) => void {
18
14
  const latest = useRef(emptyLabel);
19
15
  const reconcile = useRef<(() => void) | undefined>(undefined);
@@ -30,49 +26,64 @@ export function useEmptyLines(model: FileTree, frame: HTMLElement | null, emptyL
30
26
  if (!enabled) {
31
27
  return;
32
28
  }
29
+
33
30
  const added = new Set<string>();
34
31
  let pending = false;
35
32
  let disposed = false;
33
+
36
34
  const run = (): void => {
37
35
  pending = false;
38
36
  const labelOf = latest.current;
37
+
39
38
  if (disposed || labelOf === undefined) {
40
39
  return;
41
40
  }
41
+
42
42
  const selectedLines = model.getSelectedPaths().filter(isEmptyLine);
43
+
43
44
  for (const path of selectedLines) {
44
45
  model.getItem(path)?.deselect();
45
46
  }
47
+
46
48
  // A selection that followed focus onto the line follows it on to where it went.
47
49
  const focused = skipEmptyLine(model, lastKey.current) ? model.getFocusedPath() : null;
50
+
48
51
  if (focused !== null && selectedLines.length > 0) {
49
52
  model.getItem(focused)?.select();
50
53
  }
54
+
51
55
  const { add, remove } = emptyLineChanges(visibleRows(model), labelOf, (path) => model.getItem(path) !== null, added);
56
+
52
57
  for (const path of remove) {
53
58
  added.delete(path);
54
59
  }
60
+
55
61
  for (const path of add) {
56
62
  added.add(path);
57
63
  }
64
+
58
65
  if (add.length > 0 || remove.length > 0) {
59
66
  model.batch([...remove.map((path) => ({ type: 'remove' as const, path })), ...add.map((path) => ({ type: 'add' as const, path }))]);
60
67
  }
61
68
  };
69
+
62
70
  const schedule = (): void => {
63
71
  if (!pending && !disposed) {
64
72
  pending = true;
65
73
  queueMicrotask(run);
66
74
  }
67
75
  };
76
+
68
77
  reconcile.current = schedule;
69
78
  const unsubscribe = model.subscribe(schedule);
70
79
  run();
80
+
71
81
  return () => {
72
82
  disposed = true;
73
83
  reconcile.current = undefined;
74
84
  unsubscribe();
75
85
  const left = [...added].filter((path) => model.getItem(path) !== null);
86
+
76
87
  if (left.length > 0) {
77
88
  model.batch(left.map((path) => ({ type: 'remove' as const, path })));
78
89
  }
@@ -83,15 +94,18 @@ export function useEmptyLines(model: FileTree, frame: HTMLElement | null, emptyL
83
94
  if (frame === null) {
84
95
  return;
85
96
  }
97
+
86
98
  const block = (event: Event): void => {
87
99
  if (onEmptyLine(event)) {
88
100
  event.preventDefault();
89
101
  event.stopImmediatePropagation();
90
102
  }
91
103
  };
104
+
92
105
  for (const type of POINTER_EVENTS) {
93
106
  frame.addEventListener(type, block, true);
94
107
  }
108
+
95
109
  return () => {
96
110
  for (const type of POINTER_EVENTS) {
97
111
  frame.removeEventListener(type, block, true);
@@ -102,12 +116,15 @@ export function useEmptyLines(model: FileTree, frame: HTMLElement | null, emptyL
102
116
  return (event: KeyboardEvent): void => {
103
117
  lastKey.current = event.key;
104
118
  const past = latest.current !== undefined && movesFocus(event) ? pastEmptyLine(model, event.key) : null;
119
+
105
120
  if (past === null) {
106
121
  return;
107
122
  }
123
+
108
124
  // The engine never sees this key, so focus never lands on the line for a moment.
109
125
  event.preventDefault();
110
126
  event.stopPropagation();
127
+
111
128
  if (past !== model.getFocusedPath()) {
112
129
  model.scrollToPath(past, { focus: true, offset: 'nearest' });
113
130
  }
@@ -13,28 +13,37 @@ function limitOf(row: HTMLElement, content: Element, gap: number): number {
13
13
  const start = content.getBoundingClientRect().left;
14
14
  const contentMargin = Number.parseFloat(getComputedStyle(content).marginInlineEnd) || 0;
15
15
  let limit = Number.POSITIVE_INFINITY;
16
+
16
17
  for (const part of [...row.children].filter((child) => child !== content).flatMap((child) => boxesOf(child))) {
17
18
  const rect = part.getBoundingClientRect();
19
+
18
20
  if (rect.width === 0 || rect.left < start) {
19
21
  continue;
20
22
  }
23
+
21
24
  if (part.childNodes.length === 0 && Number.parseFloat(getComputedStyle(part).flexGrow) > 0) {
22
25
  continue;
23
26
  }
27
+
24
28
  limit = Math.min(limit, rect.left - gap);
25
29
  }
30
+
26
31
  if (limit !== Number.POSITIVE_INFINITY) {
27
32
  return limit - contentMargin;
28
33
  }
34
+
29
35
  return row.getBoundingClientRect().right - Number.parseFloat(getComputedStyle(row).paddingInlineEnd) - contentMargin;
30
36
  }
31
37
 
32
38
  function needOf(row: HTMLElement, shift: number, gap: number, range: Range): number | null {
33
39
  const content = row.querySelector(':scope > [data-item-section="content"]');
40
+
34
41
  if (content === null || row.getBoundingClientRect().width === 0) {
35
42
  return null;
36
43
  }
44
+
37
45
  range.selectNodeContents(content);
46
+
38
47
  return shiftNeed(range.getBoundingClientRect().right, limitOf(row, content, gap), shift);
39
48
  }
40
49
 
@@ -45,5 +54,6 @@ export function useFileTreeShift(model: FileTree, resetKey?: string): { attach(n
45
54
  rows: (root) => [...root.querySelectorAll<HTMLElement>(ROWS)],
46
55
  need: needOf
47
56
  };
57
+
48
58
  return useTreeShift(source, resetKey);
49
59
  }
@@ -13,9 +13,11 @@ export type FileTreeOptions = WithoutSizing<PierreOptions>;
13
13
  // Model options are initial; paths and status can subsequently change through the model's mutation methods.
14
14
  export function useFileTree(options: FileTreeOptions) {
15
15
  const latest = useRef(options.onSelectionChange);
16
+
16
17
  useLayoutEffect(() => {
17
18
  latest.current = options.onSelectionChange;
18
19
  }, [options.onSelectionChange]);
20
+
19
21
  return usePierreFileTree({
20
22
  composition: { contextMenu: { enabled: false } },
21
23
  icons: FILE_TREE_ICONS,