@adecore/database 0.16.0 → 0.17.0-beta.2

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.
package/LICENSE CHANGED
@@ -1,13 +1,102 @@
1
- MIT License
1
+ # Functional Source License, Version 1.1, MIT Future License
2
2
 
3
- Copyright (c) 2026 Bas Milius
3
+ ## Abbreviation
4
4
 
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
5
+ FSL-1.1-MIT
6
+
7
+ ## Notice
8
+
9
+ Copyright 2026 - present Bas Milius
10
+
11
+ ## Terms and Conditions
12
+
13
+ ### Licensor ("We")
14
+
15
+ The party offering the Software under these Terms and Conditions.
16
+
17
+ ### The Software
18
+
19
+ The "Software" is each version of the software that we make available under
20
+ these Terms and Conditions, as indicated by our inclusion of these Terms and
21
+ Conditions with the Software.
22
+
23
+ ### License Grant
24
+
25
+ Subject to your compliance with this License Grant and the Patents,
26
+ Redistribution and Trademark clauses below, we hereby grant you the right to
27
+ use, copy, modify, create derivative works, publicly perform, publicly display
28
+ and redistribute the Software for any Permitted Purpose identified below.
29
+
30
+ ### Permitted Purpose
31
+
32
+ A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
33
+ means making the Software available to others in a commercial product or
34
+ service that:
35
+
36
+ 1. substitutes for the Software;
37
+
38
+ 2. substitutes for any other product or service we offer using the Software
39
+ that exists as of the date we make the Software available; or
40
+
41
+ 3. offers the same or substantially similar functionality as the Software.
42
+
43
+ Permitted Purposes specifically include using the Software:
44
+
45
+ 1. for your internal use and access;
46
+
47
+ 2. for non-commercial education;
48
+
49
+ 3. for non-commercial research; and
50
+
51
+ 4. in connection with professional services that you provide to a licensee
52
+ using the Software in accordance with these Terms and Conditions.
53
+
54
+ ### Patents
55
+
56
+ To the extent your use for a Permitted Purpose would necessarily infringe our
57
+ patents, the license grant above includes a license under our patents. If you
58
+ make a claim against any party that the Software infringes or contributes to
59
+ the infringement of any patent, then your patent license to the Software ends
60
+ immediately.
61
+
62
+ ### Redistribution
63
+
64
+ The Terms and Conditions apply to all copies, modifications and derivatives of
65
+ the Software.
66
+
67
+ If you redistribute any copies, modifications or derivatives of the Software,
68
+ you must include a copy of or a link to these Terms and Conditions and not
69
+ remove any copyright notices provided in or with the Software.
70
+
71
+ ### Disclaimer
72
+
73
+ THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
74
+ IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
75
+ PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
76
+
77
+ IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
78
+ SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
79
+ EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
80
+
81
+ ### Trademarks
82
+
83
+ Except for displaying the License Details and identifying us as the origin of
84
+ the Software, you have no right under these Terms and Conditions to use our
85
+ trademarks, trade names, service marks or product names.
86
+
87
+ ## Grant of Future License
88
+
89
+ We hereby irrevocably grant you an additional license to use the Software under
90
+ the MIT license that is effective on the second anniversary of the date we make
91
+ the Software available. On or after that date, you may use the Software under
92
+ the MIT license, in which case the following will apply:
93
+
94
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
95
+ this software and associated documentation files (the "Software"), to deal in
96
+ the Software without restriction, including without limitation the rights to
97
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
98
+ of the Software, and to permit persons to whom the Software is furnished to do
99
+ so, subject to the following conditions:
11
100
 
12
101
  The above copyright notice and this permission notice shall be included in all
13
102
  copies or substantial portions of the Software.
package/README.md CHANGED
@@ -151,4 +151,4 @@ The documentation at https://adecore.dev/database/ has a page for each view, and
151
151
 
152
152
  ## License
153
153
 
154
- MIT
154
+ FSL-1.1-MIT, see [LICENSE](./LICENSE).
@@ -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,8 +1,8 @@
1
1
  {
2
2
  "name": "@adecore/database",
3
- "version": "0.16.0",
3
+ "version": "0.17.0-beta.2",
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
- "license": "MIT",
5
+ "license": "FSL-1.1-MIT",
6
6
  "author": "Bas Milius",
7
7
  "repository": {
8
8
  "type": "git",
@@ -61,13 +61,13 @@
61
61
  "lucide-react": "^1.47.0"
62
62
  },
63
63
  "optionalDependencies": {
64
- "@adecore/database-darwin-arm64": "0.16.0",
65
- "@adecore/database-linux-arm64": "0.16.0",
66
- "@adecore/database-linux-x64": "0.16.0",
67
- "@adecore/database-win32-x64": "0.16.0"
64
+ "@adecore/database-darwin-arm64": "0.17.0-beta.2",
65
+ "@adecore/database-linux-arm64": "0.17.0-beta.2",
66
+ "@adecore/database-linux-x64": "0.17.0-beta.2",
67
+ "@adecore/database-win32-x64": "0.17.0-beta.2"
68
68
  },
69
69
  "peerDependencies": {
70
- "@adecore/ui": "^0.16.0",
70
+ "@adecore/ui": "^0.17.0-beta.2",
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>