@adecore/database 0.16.0-beta.0 → 0.17.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from 'react';
3
3
  import clsx from 'clsx';
4
- import { ChevronRight, CircleAlert, Database, Eye, Folder, KeyRound, Link2, Lock, RectangleVertical, Search, Table } from 'lucide-react';
4
+ import { CircleAlert, Database, Eye, Folder, KeyRound, Link2, Lock, RectangleVertical, Search, Table } from 'lucide-react';
5
5
  import { useTranslation } from 'react-i18next';
6
- import { Button, ContextMenu, EmptyState, Icon, Input, Spinner, Tooltip } from '@adecore/ui';
6
+ import { Button, ContextMenu, EmptyState, Icon, Input, Spinner, Tooltip, Tree } from '@adecore/ui';
7
7
  import { formatNumber } from '@adecore/ui/format';
8
8
  import { useDatabaseAction, useDatabaseClient } from '../client-context.js';
9
9
  import { EngineIcon } from '../connections/EngineIcon.js';
@@ -13,10 +13,6 @@ import { connectionKey, flattenTree, hasMenu, isExpandable, isFolderKey, isSelec
13
13
  import { useExplorerLoads } from './use-explorer-loads.js';
14
14
  import { usePopupPress } from '../use-popup-press.js';
15
15
  import { useStoredExpansion } from './use-stored-expansion.js';
16
- const INDENT = 16;
17
- /* Where the chevron slot of the first level starts inside a row. */
18
- const BASE_INDENT = 4;
19
- const CHEVRON_SIZE = 16;
20
16
  /*
21
17
  * The connections, their schemas and their tables as a tree, loaded as each node opens. Every row
22
18
  * selects; a double click or Enter on a table asks the app to open it through `useDatabaseAction`.
@@ -174,18 +170,17 @@ export function DatabaseExplorer({ connections, value, defaultValue = null, onVa
174
170
  const menu = connection !== undefined && hasMenu(row) ? (_jsx(RowMenu, { row: row, connection: connection, onRefresh: refresh, onDisconnect: disconnect, onRequest: setRequest })) : null;
175
171
  const selectedRow = isSelected(row);
176
172
  const props = {
177
- role: 'treeitem',
178
- 'aria-level': row.level,
173
+ level: row.level,
174
+ selected: selectedRow,
175
+ joinedStart: selectedRow && isSelected(rows[index - 1]),
176
+ joinedEnd: selectedRow && isSelected(rows[index + 1]),
177
+ interactive: row.focusable,
179
178
  'aria-setsize': row.setSize,
180
179
  'aria-posinset': row.posInSet,
181
180
  'aria-expanded': isExpandable(row) ? row.expanded : undefined,
182
181
  'aria-selected': isSelectable(row) ? selectedRow : undefined,
183
182
  'aria-busy': row.kind === 'loading' ? true : undefined,
184
183
  tabIndex: row.focusable ? (row.key === stop ? 0 : -1) : undefined,
185
- className: clsx(
186
- // The line box is the row's height, so the text lands on whole pixels in a row of an odd height.
187
- 'focus-ring relative mx-2 flex h-[25px] min-w-0 items-center gap-1.5 rounded-md pr-2 text-xs leading-[25px] text-text focus-visible:-outline-offset-2', selectedRow ? 'bg-surface-active' : row.focusable && 'hover:bg-surface-hover', selectedRow && isSelected(rows[index - 1]) && 'rounded-t-none', selectedRow && isSelected(rows[index + 1]) && 'rounded-b-none'),
188
- style: { paddingInlineStart: BASE_INDENT + (row.level - 1) * INDENT },
189
184
  ref: (element) => {
190
185
  if (element === null) {
191
186
  rowElements.current.delete(row.key);
@@ -202,50 +197,40 @@ export function DatabaseExplorer({ connections, value, defaultValue = null, onVa
202
197
  }
203
198
  }
204
199
  };
205
- const content = (_jsxs(_Fragment, { children: [Array.from({ length: row.level - 1 }, (_, i) => (_jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute inset-y-0 w-px bg-border", style: { insetInlineStart: BASE_INDENT + i * INDENT + CHEVRON_SIZE / 2 - 1 } }, i))), _jsx(RowContent, { row: row, onToggle: isExpandable(row) ? () => toggle(row) : undefined })] }));
200
+ const content = _jsx(RowContent, { row: row, onToggle: isExpandable(row) ? () => toggle(row) : undefined });
206
201
  if (menu === null) {
207
- return (_jsx("div", { ...props, children: content }, row.key));
202
+ return (_jsx(Tree.Row, { ...props, children: content }, row.key));
208
203
  }
209
- return (_jsxs(ContextMenu.Root, { children: [_jsx(ContextMenu.Trigger, { render: _jsx("div", { ...props }), children: content }), _jsx(ContextMenu.Popup, { children: menu })] }, row.key));
204
+ return (_jsxs(ContextMenu.Root, { children: [_jsx(ContextMenu.Trigger, { render: _jsx(Tree.Row, { ...props }), children: content }), _jsx(ContextMenu.Popup, { children: menu })] }, row.key));
210
205
  };
211
206
  return (_jsxs("div", { ref: ref, className: clsx('flex min-h-0 min-w-0 flex-col', className), children: [_jsx("div", { className: "shrink-0 border-b border-border p-2", children: _jsx(Input, { size: "sm", icon: Search, type: "search", value: filter, placeholder: t('explorer.filter'), "aria-label": t('explorer.filter'), spellCheck: false, onChange: (e) => setFilter(e.target.value), onKeyDown: (e) => {
212
207
  if (e.key === 'Escape') {
213
208
  setFilter('');
214
209
  }
215
- } }) }), connections.length === 0 ? (_jsx(EmptyState, { icon: Database, children: t('explorer.noConnections') })) : rows.length === 0 ? (_jsx(EmptyState, { icon: Search, children: t('explorer.noMatches', { query: filter.trim() }) })) : (_jsx("div", { role: "tree", "aria-label": t('explorer.label'), className: "min-h-0 grow overflow-y-auto py-2", onKeyDown: onKeyDown, children: rows.map(renderRow) })), request !== null && _jsx(TableDialog, { request: request, onClose: () => setRequest(null), onDone: finish })] }));
210
+ } }) }), connections.length === 0 ? (_jsx(EmptyState, { icon: Database, children: t('explorer.noConnections') })) : rows.length === 0 ? (_jsx(EmptyState, { icon: Search, children: t('explorer.noMatches', { query: filter.trim() }) })) : (_jsx(Tree.Root, { "aria-label": t('explorer.label'), className: "min-h-0 grow overflow-y-auto py-2", onKeyDown: onKeyDown, children: rows.map(renderRow) })), request !== null && _jsx(TableDialog, { request: request, onClose: () => setRequest(null), onDone: finish })] }));
216
211
  }
217
212
  function RowContent({ row, onToggle }) {
218
213
  const { t } = useTranslation('database');
219
214
  switch (row.kind) {
220
215
  case 'connection':
221
- return (_jsxs(_Fragment, { children: [_jsx(Chevron, { open: row.expanded, onToggle: onToggle }), _jsx(EngineIcon, { engine: row.connection.config.engine, size: 16, className: "shrink-0 text-text-muted" }), _jsx("span", { className: "min-w-0 truncate", children: row.connection.name || t('connections.untitled') }), row.connection.config.readOnly === true && (_jsx(Tooltip, { label: t('explorer.readOnly'), children: _jsx("span", { className: "inline-flex shrink-0 text-text-faint", children: _jsx(Icon, { icon: Lock, size: 12 }) }) })), row.version !== null && _jsx("span", { className: "min-w-0 shrink-[2] truncate text-text-faint", children: row.version }), row.schemaCount !== null && _jsx(SchemaCount, { shown: row.schemaCount.shown, total: row.schemaCount.total })] }));
216
+ return (_jsxs(_Fragment, { children: [_jsx(Tree.Chevron, { expanded: row.expanded, onExpandedChange: onToggle }), _jsx(EngineIcon, { engine: row.connection.config.engine, size: 16, className: "shrink-0 text-text-muted" }), _jsx(Tree.Label, { children: row.connection.name || t('connections.untitled') }), row.connection.config.readOnly === true && (_jsx(Tooltip, { label: t('explorer.readOnly'), children: _jsx("span", { className: "inline-flex shrink-0 text-text-faint", children: _jsx(Icon, { icon: Lock, size: 12 }) }) })), row.version !== null && _jsx("span", { className: "min-w-0 shrink-[2] truncate text-text-faint", children: row.version }), row.schemaCount !== null && _jsx(SchemaCount, { shown: row.schemaCount.shown, total: row.schemaCount.total })] }));
222
217
  case 'schema':
223
- return (_jsxs(_Fragment, { children: [_jsx(Chevron, { open: row.expanded, onToggle: onToggle }), _jsx(Icon, { icon: Database, size: 16, className: "shrink-0 text-text-muted" }), _jsx("span", { className: "min-w-0 truncate", children: row.schema })] }));
218
+ return (_jsxs(_Fragment, { children: [_jsx(Tree.Chevron, { expanded: row.expanded, onExpandedChange: onToggle }), _jsx(Icon, { icon: Database, size: 16, className: "shrink-0 text-text-muted" }), _jsx(Tree.Label, { children: row.schema })] }));
224
219
  case 'folder':
225
- return (_jsxs(_Fragment, { children: [_jsx(Chevron, { open: row.expanded, onToggle: onToggle }), _jsx(Icon, { icon: Folder, size: 16, className: "shrink-0 text-text-muted" }), _jsx("span", { className: "min-w-0 truncate", children: t(row.group === 'view' ? 'explorer.views' : 'explorer.tables') }), _jsx("span", { className: "shrink-0 text-text-faint tabular-nums", children: formatNumber(row.count) })] }));
220
+ return (_jsxs(_Fragment, { children: [_jsx(Tree.Chevron, { expanded: row.expanded, onExpandedChange: onToggle }), _jsx(Icon, { icon: Folder, size: 16, className: "shrink-0 text-text-muted" }), _jsx(Tree.Label, { children: t(row.group === 'view' ? 'explorer.views' : 'explorer.tables') }), _jsx("span", { className: "shrink-0 text-text-faint tabular-nums", children: formatNumber(row.count) })] }));
226
221
  case 'table':
227
- return (_jsxs(_Fragment, { children: [_jsx(Chevron, { open: row.expanded, onToggle: onToggle }), _jsx(Icon, { icon: row.table.kind === 'view' ? Eye : Table, size: 16, className: "shrink-0 text-text-muted" }), _jsx("span", { className: "min-w-0 truncate", children: row.table.name })] }));
222
+ return (_jsxs(_Fragment, { children: [_jsx(Tree.Chevron, { expanded: row.expanded, onExpandedChange: onToggle }), _jsx(Icon, { icon: row.table.kind === 'view' ? Eye : Table, size: 16, className: "shrink-0 text-text-muted" }), _jsx(Tree.Label, { children: row.table.name })] }));
228
223
  case 'column':
229
- return (_jsxs(_Fragment, { children: [_jsx(ChevronSlot, {}), _jsx(ColumnIcon, { primaryKey: row.primaryKey, foreignKey: row.foreignKey }), _jsx("span", { className: "min-w-0 truncate", children: row.column.name }), row.column.type !== '' && _jsx("span", { className: "min-w-0 shrink-[2] truncate text-text-faint", children: row.column.type })] }));
224
+ return (_jsxs(_Fragment, { children: [_jsx(Tree.ChevronSlot, {}), _jsx(ColumnIcon, { primaryKey: row.primaryKey, foreignKey: row.foreignKey }), _jsx(Tree.Label, { children: row.column.name }), row.column.type !== '' && _jsx("span", { className: "min-w-0 shrink-[2] truncate text-text-faint", children: row.column.type })] }));
230
225
  case 'loading':
231
- return (_jsxs(_Fragment, { children: [_jsx(ChevronSlot, {}), _jsx(Spinner, { size: 12 }), _jsx("span", { className: "text-text-faint", children: t('explorer.loading') })] }));
226
+ return (_jsxs(_Fragment, { children: [_jsx(Tree.ChevronSlot, {}), _jsx(Spinner, { size: 12 }), _jsx("span", { className: "text-text-faint", children: t('explorer.loading') })] }));
232
227
  case 'error':
233
228
  return _jsx(ErrorContent, { row: row });
234
229
  case 'empty':
235
- return (_jsxs(_Fragment, { children: [_jsx(ChevronSlot, {}), _jsx("span", { className: "text-text-faint", children: t(EMPTY_WORDS[row.of]) })] }));
230
+ return (_jsxs(_Fragment, { children: [_jsx(Tree.ChevronSlot, {}), _jsx("span", { className: "text-text-faint", children: t(EMPTY_WORDS[row.of]) })] }));
236
231
  }
237
232
  }
238
233
  const EMPTY_WORDS = { schemas: 'explorer.noSchemas', tables: 'explorer.noTables', columns: 'explorer.noColumns' };
239
- /* Where a chevron would be, so a row without one lines up with its siblings that have one. */
240
- function ChevronSlot() {
241
- return _jsx("span", { className: "size-4 shrink-0" });
242
- }
243
- function Chevron({ open, onToggle }) {
244
- return (_jsx("span", { className: "grid size-4 shrink-0 place-items-center text-text-muted", onClick: (e) => {
245
- e.stopPropagation();
246
- onToggle?.();
247
- }, onDoubleClick: (e) => e.stopPropagation(), children: _jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('transition-transform', open && 'rotate-90') }) }));
248
- }
249
234
  function SchemaCount({ shown, total }) {
250
235
  const { t } = useTranslation('database');
251
236
  const counts = { shown: formatNumber(shown), total: formatNumber(total) };
@@ -260,5 +245,5 @@ function ColumnIcon({ primaryKey, foreignKey }) {
260
245
  }
261
246
  function ErrorContent({ row }) {
262
247
  const { t } = useTranslation('database');
263
- return (_jsxs(_Fragment, { children: [_jsx(ChevronSlot, {}), _jsx(Icon, { icon: CircleAlert, size: 16, className: "shrink-0 text-status-error" }), _jsx(Tooltip, { label: row.message, children: _jsx("span", { className: "min-w-0 truncate text-status-error", children: row.message }) }), _jsx(Button, { size: "xs", variant: "ghost", className: "ml-auto", tabIndex: -1, children: t('explorer.retry') })] }));
248
+ return (_jsxs(_Fragment, { children: [_jsx(Tree.ChevronSlot, {}), _jsx(Icon, { icon: CircleAlert, size: 16, className: "shrink-0 text-status-error" }), _jsx(Tooltip, { label: row.message, children: _jsx("span", { className: "min-w-0 truncate text-status-error", children: row.message }) }), _jsx(Button, { size: "xs", variant: "ghost", className: "ml-auto", tabIndex: -1, children: t('explorer.retry') })] }));
264
249
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/database",
3
- "version": "0.16.0-beta.0",
3
+ "version": "0.17.0-beta.1",
4
4
  "description": "Browse, query and edit SQLite and MySQL databases: React views for the page, a host for the backend and a native helper that talks to the server.",
5
5
  "license": "MIT",
6
6
  "author": "Bas Milius",
@@ -61,13 +61,13 @@
61
61
  "lucide-react": "^1.47.0"
62
62
  },
63
63
  "optionalDependencies": {
64
- "@adecore/database-darwin-arm64": "0.16.0-beta.0",
65
- "@adecore/database-linux-arm64": "0.16.0-beta.0",
66
- "@adecore/database-linux-x64": "0.16.0-beta.0",
67
- "@adecore/database-win32-x64": "0.16.0-beta.0"
64
+ "@adecore/database-darwin-arm64": "0.17.0-beta.1",
65
+ "@adecore/database-linux-arm64": "0.17.0-beta.1",
66
+ "@adecore/database-linux-x64": "0.17.0-beta.1",
67
+ "@adecore/database-win32-x64": "0.17.0-beta.1"
68
68
  },
69
69
  "peerDependencies": {
70
- "@adecore/ui": "^0.16.0-beta.0",
70
+ "@adecore/ui": "^0.17.0-beta.1",
71
71
  "i18next": "^26.4.2",
72
72
  "react": "^19.3.0",
73
73
  "react-dom": "^19.3.0",
@@ -1,8 +1,8 @@
1
1
  import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode, type Ref } from 'react';
2
2
  import clsx from 'clsx';
3
- import { ChevronRight, CircleAlert, Database, Eye, Folder, KeyRound, Link2, Lock, RectangleVertical, Search, Table } from 'lucide-react';
3
+ import { CircleAlert, Database, Eye, Folder, KeyRound, Link2, Lock, RectangleVertical, Search, Table } from 'lucide-react';
4
4
  import { useTranslation } from 'react-i18next';
5
- import { Button, ContextMenu, EmptyState, Icon, Input, Spinner, Tooltip } from '@adecore/ui';
5
+ import { Button, ContextMenu, EmptyState, Icon, Input, Spinner, Tooltip, Tree } from '@adecore/ui';
6
6
  import { formatNumber } from '@adecore/ui/format';
7
7
  import type { ExplorerSelection } from '../actions.ts';
8
8
  import { useDatabaseAction, useDatabaseClient } from '../client-context.ts';
@@ -32,11 +32,6 @@ import { useExplorerLoads } from './use-explorer-loads.ts';
32
32
  import { usePopupPress } from '../use-popup-press.ts';
33
33
  import { useStoredExpansion } from './use-stored-expansion.ts';
34
34
 
35
- const INDENT = 16;
36
- /* Where the chevron slot of the first level starts inside a row. */
37
- const BASE_INDENT = 4;
38
- const CHEVRON_SIZE = 16;
39
-
40
35
  export interface DatabaseExplorerProps {
41
36
  connections: readonly Connection[];
42
37
  /* The selected connection, schema or table. Without it the explorer keeps the selection itself, starting at `defaultValue`. */
@@ -221,22 +216,17 @@ export function DatabaseExplorer({ connections, value, defaultValue = null, onVa
221
216
  ) : null;
222
217
  const selectedRow = isSelected(row);
223
218
  const props = {
224
- role: 'treeitem',
225
- 'aria-level': row.level,
219
+ level: row.level,
220
+ selected: selectedRow,
221
+ joinedStart: selectedRow && isSelected(rows[index - 1]),
222
+ joinedEnd: selectedRow && isSelected(rows[index + 1]),
223
+ interactive: row.focusable,
226
224
  'aria-setsize': row.setSize,
227
225
  'aria-posinset': row.posInSet,
228
226
  'aria-expanded': isExpandable(row) ? row.expanded : undefined,
229
227
  'aria-selected': isSelectable(row) ? selectedRow : undefined,
230
228
  'aria-busy': row.kind === 'loading' ? true : undefined,
231
229
  tabIndex: row.focusable ? (row.key === stop ? 0 : -1) : undefined,
232
- className: clsx(
233
- // The line box is the row's height, so the text lands on whole pixels in a row of an odd height.
234
- 'focus-ring relative mx-2 flex h-[25px] min-w-0 items-center gap-1.5 rounded-md pr-2 text-xs leading-[25px] text-text focus-visible:-outline-offset-2',
235
- selectedRow ? 'bg-surface-active' : row.focusable && 'hover:bg-surface-hover',
236
- selectedRow && isSelected(rows[index - 1]) && 'rounded-t-none',
237
- selectedRow && isSelected(rows[index + 1]) && 'rounded-b-none'
238
- ),
239
- style: { paddingInlineStart: BASE_INDENT + (row.level - 1) * INDENT },
240
230
  ref: (element: HTMLElement | null) => {
241
231
  if (element === null) {
242
232
  rowElements.current.delete(row.key);
@@ -252,29 +242,17 @@ export function DatabaseExplorer({ connections, value, defaultValue = null, onVa
252
242
  }
253
243
  }
254
244
  };
255
- const content = (
256
- <>
257
- {Array.from({ length: row.level - 1 }, (_, i) => (
258
- <span
259
- key={i}
260
- aria-hidden
261
- className="pointer-events-none absolute inset-y-0 w-px bg-border"
262
- style={{ insetInlineStart: BASE_INDENT + i * INDENT + CHEVRON_SIZE / 2 - 1 }}
263
- />
264
- ))}
265
- <RowContent row={row} onToggle={isExpandable(row) ? () => toggle(row) : undefined} />
266
- </>
267
- );
245
+ const content = <RowContent row={row} onToggle={isExpandable(row) ? () => toggle(row) : undefined} />;
268
246
  if (menu === null) {
269
247
  return (
270
- <div key={row.key} {...props}>
248
+ <Tree.Row key={row.key} {...props}>
271
249
  {content}
272
- </div>
250
+ </Tree.Row>
273
251
  );
274
252
  }
275
253
  return (
276
254
  <ContextMenu.Root key={row.key}>
277
- <ContextMenu.Trigger render={<div {...props} />}>{content}</ContextMenu.Trigger>
255
+ <ContextMenu.Trigger render={<Tree.Row {...props} />}>{content}</ContextMenu.Trigger>
278
256
  <ContextMenu.Popup>{menu}</ContextMenu.Popup>
279
257
  </ContextMenu.Root>
280
258
  );
@@ -304,9 +282,9 @@ export function DatabaseExplorer({ connections, value, defaultValue = null, onVa
304
282
  ) : rows.length === 0 ? (
305
283
  <EmptyState icon={Search}>{t('explorer.noMatches', { query: filter.trim() })}</EmptyState>
306
284
  ) : (
307
- <div role="tree" aria-label={t('explorer.label')} className="min-h-0 grow overflow-y-auto py-2" onKeyDown={onKeyDown}>
285
+ <Tree.Root aria-label={t('explorer.label')} className="min-h-0 grow overflow-y-auto py-2" onKeyDown={onKeyDown}>
308
286
  {rows.map(renderRow)}
309
- </div>
287
+ </Tree.Root>
310
288
  )}
311
289
  {request !== null && <TableDialog request={request} onClose={() => setRequest(null)} onDone={finish} />}
312
290
  </div>
@@ -319,9 +297,9 @@ function RowContent({ row, onToggle }: { row: TreeRow; onToggle?: () => void })
319
297
  case 'connection':
320
298
  return (
321
299
  <>
322
- <Chevron open={row.expanded} onToggle={onToggle} />
300
+ <Tree.Chevron expanded={row.expanded} onExpandedChange={onToggle} />
323
301
  <EngineIcon engine={row.connection.config.engine} size={16} className="shrink-0 text-text-muted" />
324
- <span className="min-w-0 truncate">{row.connection.name || t('connections.untitled')}</span>
302
+ <Tree.Label>{row.connection.name || t('connections.untitled')}</Tree.Label>
325
303
  {row.connection.config.readOnly === true && (
326
304
  <Tooltip label={t('explorer.readOnly')}>
327
305
  <span className="inline-flex shrink-0 text-text-faint">
@@ -336,41 +314,41 @@ function RowContent({ row, onToggle }: { row: TreeRow; onToggle?: () => void })
336
314
  case 'schema':
337
315
  return (
338
316
  <>
339
- <Chevron open={row.expanded} onToggle={onToggle} />
317
+ <Tree.Chevron expanded={row.expanded} onExpandedChange={onToggle} />
340
318
  <Icon icon={Database} size={16} className="shrink-0 text-text-muted" />
341
- <span className="min-w-0 truncate">{row.schema}</span>
319
+ <Tree.Label>{row.schema}</Tree.Label>
342
320
  </>
343
321
  );
344
322
  case 'folder':
345
323
  return (
346
324
  <>
347
- <Chevron open={row.expanded} onToggle={onToggle} />
325
+ <Tree.Chevron expanded={row.expanded} onExpandedChange={onToggle} />
348
326
  <Icon icon={Folder} size={16} className="shrink-0 text-text-muted" />
349
- <span className="min-w-0 truncate">{t(row.group === 'view' ? 'explorer.views' : 'explorer.tables')}</span>
327
+ <Tree.Label>{t(row.group === 'view' ? 'explorer.views' : 'explorer.tables')}</Tree.Label>
350
328
  <span className="shrink-0 text-text-faint tabular-nums">{formatNumber(row.count)}</span>
351
329
  </>
352
330
  );
353
331
  case 'table':
354
332
  return (
355
333
  <>
356
- <Chevron open={row.expanded} onToggle={onToggle} />
334
+ <Tree.Chevron expanded={row.expanded} onExpandedChange={onToggle} />
357
335
  <Icon icon={row.table.kind === 'view' ? Eye : Table} size={16} className="shrink-0 text-text-muted" />
358
- <span className="min-w-0 truncate">{row.table.name}</span>
336
+ <Tree.Label>{row.table.name}</Tree.Label>
359
337
  </>
360
338
  );
361
339
  case 'column':
362
340
  return (
363
341
  <>
364
- <ChevronSlot />
342
+ <Tree.ChevronSlot />
365
343
  <ColumnIcon primaryKey={row.primaryKey} foreignKey={row.foreignKey} />
366
- <span className="min-w-0 truncate">{row.column.name}</span>
344
+ <Tree.Label>{row.column.name}</Tree.Label>
367
345
  {row.column.type !== '' && <span className="min-w-0 shrink-[2] truncate text-text-faint">{row.column.type}</span>}
368
346
  </>
369
347
  );
370
348
  case 'loading':
371
349
  return (
372
350
  <>
373
- <ChevronSlot />
351
+ <Tree.ChevronSlot />
374
352
  <Spinner size={12} />
375
353
  <span className="text-text-faint">{t('explorer.loading')}</span>
376
354
  </>
@@ -380,7 +358,7 @@ function RowContent({ row, onToggle }: { row: TreeRow; onToggle?: () => void })
380
358
  case 'empty':
381
359
  return (
382
360
  <>
383
- <ChevronSlot />
361
+ <Tree.ChevronSlot />
384
362
  <span className="text-text-faint">{t(EMPTY_WORDS[row.of])}</span>
385
363
  </>
386
364
  );
@@ -389,26 +367,6 @@ function RowContent({ row, onToggle }: { row: TreeRow; onToggle?: () => void })
389
367
 
390
368
  const EMPTY_WORDS = { schemas: 'explorer.noSchemas', tables: 'explorer.noTables', columns: 'explorer.noColumns' } as const;
391
369
 
392
- /* Where a chevron would be, so a row without one lines up with its siblings that have one. */
393
- function ChevronSlot() {
394
- return <span className="size-4 shrink-0" />;
395
- }
396
-
397
- function Chevron({ open, onToggle }: { open: boolean; onToggle?: () => void }) {
398
- return (
399
- <span
400
- className="grid size-4 shrink-0 place-items-center text-text-muted"
401
- onClick={(e) => {
402
- e.stopPropagation();
403
- onToggle?.();
404
- }}
405
- onDoubleClick={(e) => e.stopPropagation()}
406
- >
407
- <Icon icon={ChevronRight} size={12} className={clsx('transition-transform', open && 'rotate-90')} />
408
- </span>
409
- );
410
- }
411
-
412
370
  function SchemaCount({ shown, total }: { shown: number; total: number }) {
413
371
  const { t } = useTranslation('database');
414
372
  const counts = { shown: formatNumber(shown), total: formatNumber(total) };
@@ -437,7 +395,7 @@ function ErrorContent({ row }: { row: ErrorRow }) {
437
395
  const { t } = useTranslation('database');
438
396
  return (
439
397
  <>
440
- <ChevronSlot />
398
+ <Tree.ChevronSlot />
441
399
  <Icon icon={CircleAlert} size={16} className="shrink-0 text-status-error" />
442
400
  <Tooltip label={row.message}>
443
401
  <span className="min-w-0 truncate text-status-error">{row.message}</span>