@adecore/database 0.17.0 → 0.18.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.
Files changed (135) hide show
  1. package/README.md +1 -1
  2. package/dist/DatabaseProvider.d.ts +4 -3
  3. package/dist/DatabaseProvider.js +15 -3
  4. package/dist/KeyIcon.d.ts +4 -0
  5. package/dist/KeyIcon.js +14 -0
  6. package/dist/actions.d.ts +12 -0
  7. package/dist/client-context.d.ts +3 -1
  8. package/dist/client-context.js +2 -0
  9. package/dist/code-text.d.ts +1 -0
  10. package/dist/code-text.js +2 -0
  11. package/dist/column-keys.d.ts +5 -0
  12. package/dist/column-keys.js +2 -0
  13. package/dist/connections/ConnectionManager.d.ts +3 -2
  14. package/dist/connections/ConnectionManager.js +27 -16
  15. package/dist/connections/connection-config.js +2 -0
  16. package/dist/console/QueryConsole.d.ts +8 -3
  17. package/dist/console/QueryConsole.js +110 -50
  18. package/dist/console/SqlEditor.d.ts +2 -12
  19. package/dist/console/SqlEditor.js +29 -18
  20. package/dist/console/StatementResultView.d.ts +5 -1
  21. package/dist/console/StatementResultView.js +18 -5
  22. package/dist/console/TransactionControls.js +3 -6
  23. package/dist/console/editor-slot.d.ts +18 -0
  24. package/dist/console/editor-slot.js +1 -0
  25. package/dist/console/result-keys.d.ts +15 -0
  26. package/dist/console/result-keys.js +79 -0
  27. package/dist/designer/ColumnsEditor.js +2 -2
  28. package/dist/designer/DesignerView.d.ts +3 -2
  29. package/dist/designer/DesignerView.js +5 -4
  30. package/dist/designer/SqlPreview.js +2 -1
  31. package/dist/designer/TableDesigner.d.ts +3 -2
  32. package/dist/designer/TableDesigner.js +2 -2
  33. package/dist/explorer/DatabaseExplorer.d.ts +4 -1
  34. package/dist/explorer/DatabaseExplorer.js +79 -23
  35. package/dist/explorer/RowMenu.js +2 -2
  36. package/dist/explorer/expansion.js +2 -2
  37. package/dist/explorer/folders.d.ts +16 -0
  38. package/dist/explorer/folders.js +1 -0
  39. package/dist/explorer/tree.d.ts +55 -4
  40. package/dist/explorer/tree.js +137 -23
  41. package/dist/grid/CellEditor.js +3 -1
  42. package/dist/grid/DataGrid.d.ts +2 -2
  43. package/dist/grid/DataGrid.js +61 -59
  44. package/dist/grid/EnumPicker.js +2 -1
  45. package/dist/grid/GridHeaderCell.d.ts +1 -2
  46. package/dist/grid/GridHeaderCell.js +12 -5
  47. package/dist/grid/GridRowView.d.ts +49 -0
  48. package/dist/grid/GridRowView.js +51 -0
  49. package/dist/grid/RecordView.d.ts +4 -2
  50. package/dist/grid/RecordView.js +71 -22
  51. package/dist/grid/ValueDock.d.ts +3 -1
  52. package/dist/grid/ValueDock.js +9 -3
  53. package/dist/grid/cell-id.d.ts +1 -0
  54. package/dist/grid/cell-id.js +2 -0
  55. package/dist/grid/layout.d.ts +3 -2
  56. package/dist/grid/layout.js +15 -6
  57. package/dist/grid/types.d.ts +8 -1
  58. package/dist/grid/types.js +4 -3
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +2 -0
  61. package/dist/locales/en.json +32 -15
  62. package/dist/locales/nl.json +32 -15
  63. package/dist/protocol/index.d.ts +1 -1
  64. package/dist/protocol/messages.d.ts +1 -1
  65. package/dist/protocol/messages.js +1 -1
  66. package/dist/protocol/schema.d.ts +17 -0
  67. package/dist/structure/StructureView.d.ts +5 -3
  68. package/dist/structure/StructureView.js +8 -6
  69. package/dist/table/CommandField.js +2 -2
  70. package/dist/table/TableStatusBar.js +2 -2
  71. package/dist/table/TableToolbar.d.ts +3 -4
  72. package/dist/table/TableToolbar.js +4 -3
  73. package/dist/table/TableView.d.ts +4 -2
  74. package/dist/table/TableView.js +63 -91
  75. package/dist/table/grid-rows.js +4 -1
  76. package/dist/table/useTableTransfer.d.ts +3 -1
  77. package/dist/table/useTableTransfer.js +19 -5
  78. package/dist/testing/fake.d.ts +3 -1
  79. package/dist/testing/fake.js +7 -1
  80. package/dist/value/ValueEditor.d.ts +9 -0
  81. package/dist/value/ValueEditor.js +102 -0
  82. package/dist/value/ValuePanel.d.ts +1 -1
  83. package/dist/value/ValuePanel.js +3 -94
  84. package/dist/workbench/DatabaseWorkbench.js +4 -7
  85. package/package.json +6 -6
  86. package/src/DatabaseProvider.tsx +24 -5
  87. package/src/KeyIcon.tsx +15 -0
  88. package/src/actions.ts +28 -2
  89. package/src/client-context.ts +5 -1
  90. package/src/code-text.ts +2 -0
  91. package/src/column-keys.ts +8 -0
  92. package/src/connections/ConnectionManager.tsx +98 -68
  93. package/src/connections/connection-config.ts +2 -0
  94. package/src/console/QueryConsole.tsx +246 -150
  95. package/src/console/SqlEditor.tsx +33 -37
  96. package/src/console/StatementResultView.tsx +61 -27
  97. package/src/console/TransactionControls.tsx +8 -11
  98. package/src/console/editor-slot.ts +27 -0
  99. package/src/console/result-keys.ts +109 -0
  100. package/src/designer/ColumnsEditor.tsx +3 -3
  101. package/src/designer/DesignerView.tsx +14 -3
  102. package/src/designer/SqlPreview.tsx +2 -1
  103. package/src/designer/TableDesigner.tsx +5 -2
  104. package/src/explorer/DatabaseExplorer.tsx +170 -26
  105. package/src/explorer/RowMenu.tsx +2 -2
  106. package/src/explorer/expansion.ts +4 -4
  107. package/src/explorer/folders.ts +25 -0
  108. package/src/explorer/tree.ts +219 -28
  109. package/src/grid/CellEditor.tsx +3 -2
  110. package/src/grid/DataGrid.tsx +114 -176
  111. package/src/grid/EnumPicker.tsx +2 -1
  112. package/src/grid/GridHeaderCell.tsx +14 -9
  113. package/src/grid/GridRowView.tsx +184 -0
  114. package/src/grid/RecordView.tsx +188 -75
  115. package/src/grid/ValueDock.tsx +12 -3
  116. package/src/grid/cell-id.ts +2 -0
  117. package/src/grid/layout.ts +16 -6
  118. package/src/grid/types.ts +15 -3
  119. package/src/index.ts +5 -1
  120. package/src/locales/en.json +32 -15
  121. package/src/locales/nl.json +32 -15
  122. package/src/protocol/index.ts +13 -1
  123. package/src/protocol/messages.ts +1 -1
  124. package/src/protocol/schema.ts +28 -0
  125. package/src/structure/StructureView.tsx +16 -6
  126. package/src/table/CommandField.tsx +2 -2
  127. package/src/table/TableStatusBar.tsx +3 -3
  128. package/src/table/TableToolbar.tsx +107 -92
  129. package/src/table/TableView.tsx +95 -132
  130. package/src/table/grid-rows.ts +5 -3
  131. package/src/table/useTableTransfer.ts +21 -6
  132. package/src/testing/fake.ts +18 -1
  133. package/src/value/ValueEditor.tsx +213 -0
  134. package/src/value/ValuePanel.tsx +4 -206
  135. package/src/workbench/DatabaseWorkbench.tsx +9 -10
@@ -1,103 +1,12 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo, useState } from 'react';
3
2
  import clsx from 'clsx';
4
- import { Copy, Ellipsis, MousePointerClick, WrapText, X } from 'lucide-react';
3
+ import { MousePointerClick, X } from 'lucide-react';
5
4
  import { useTranslation } from 'react-i18next';
6
- import { Button, copyText, EmptyState, FormError, IconButton, isApplePlatform, KEY_SHORTCUTS, matchesShortcut, Menu, PanelHeader, Segmented, TextArea, Tooltip } from '@adecore/ui';
7
- import { formatBytes, formatNumber } from '@adecore/ui/format';
8
- import { editValueOf, sameDraft, sameSource, sourceOf } from './draft.js';
9
- import { hexDumpLines, parseHex } from './hex.js';
10
- import { renderView, sizeOf, viewsOf } from './views.js';
11
- const WRAPPING_VIEWS = new Set(['text', 'formatted', 'utf8']);
12
- const READING = 'min-h-0 flex-1 overflow-auto rounded-lg border border-border bg-surface p-2 font-mono text-code text-text';
5
+ import { EmptyState, IconButton, PanelHeader } from '@adecore/ui';
6
+ import { ValueEditor } from './ValueEditor.js';
13
7
  /* The whole value of the focused cell beside the grid, to read and edit what a cell is too small for. */
14
8
  export function ValuePanel({ column, value, loading, editable, onCommit, onClose, className, ref }) {
15
9
  const { t } = useTranslation('database');
16
10
  const ready = column !== null && !loading && value !== undefined;
17
11
  return (_jsxs("div", { ref: ref, className: clsx('flex h-full min-h-0 min-w-0 flex-col', className), children: [_jsxs(PanelHeader, { title: column === null ? t('value.title') : undefined, children: [column !== null && (_jsxs(_Fragment, { children: [_jsx("span", { className: "min-w-0 truncate text-sm font-medium text-text", children: column.name }), column.type !== '' && _jsx("span", { className: "min-w-0 shrink-0 truncate text-xs text-text-faint", children: column.type })] })), _jsx(IconButton, { icon: X, size: "sm", label: t('value.close'), className: "ml-auto", onClick: onClose })] }), ready ? (_jsx(ValueEditor, { column: column, value: value, editable: editable, onCommit: onCommit })) : (_jsx("div", { className: "grid min-h-0 grow place-items-center", children: _jsx(EmptyState, { icon: column === null ? MousePointerClick : undefined, busy: column !== null && loading, children: column === null ? t('value.selectCell') : loading ? t('value.loading') : t('value.notLoaded') }) }))] }));
18
12
  }
19
- function ValueEditor({ column, value, editable, onCommit }) {
20
- const { t } = useTranslation('database');
21
- const { name, type, kind } = column;
22
- const source = useMemo(() => sourceOf({ name, type, kind }, value), [name, type, kind, value]);
23
- const [seen, setSeen] = useState(source);
24
- const [draft, setDraft] = useState(source.original);
25
- const [choice, setChoice] = useState(null);
26
- const [problem, setProblem] = useState(null);
27
- const [wrap, setWrap] = useState(true);
28
- // A new cell, or the same cell with a new value, starts over from that value.
29
- if (!sameSource(seen, source)) {
30
- setSeen(source);
31
- setDraft(source.original);
32
- setChoice(null);
33
- setProblem(null);
34
- }
35
- const views = viewsOf(source, draft);
36
- const view = choice !== null && views.includes(choice) ? choice : views[0];
37
- const dirty = !sameDraft(draft, source.original);
38
- const rendered = useMemo(() => (view === 'text' ? null : renderView(view, draft)), [view, draft]);
39
- const hex = source.binary && draft.mode === 'value' ? parseHex(draft.text) : null;
40
- const shownProblem = problem ?? (hex !== null && !hex.ok ? { kind: 'hex', problem: hex.problem } : null);
41
- const size = sizeOf(source, draft);
42
- const placeholder = draft.mode === 'null' ? 'NULL' : draft.mode === 'default' ? 'DEFAULT' : undefined;
43
- const change = (next) => {
44
- setDraft(next);
45
- setProblem(null);
46
- };
47
- const apply = () => {
48
- if (!editable || onCommit === undefined || !dirty) {
49
- return;
50
- }
51
- const conversion = editValueOf(draft, source);
52
- if (conversion.ok) {
53
- onCommit(conversion.value);
54
- }
55
- else {
56
- setProblem(conversion.problem);
57
- }
58
- };
59
- const handleKeyDown = (event) => {
60
- if (!event.nativeEvent.isComposing && matchesShortcut(KEY_SHORTCUTS.modEnter, event.nativeEvent, isApplePlatform())) {
61
- event.preventDefault();
62
- apply();
63
- }
64
- };
65
- const copy = () => {
66
- if (view === 'hex' && hex !== null && hex.ok) {
67
- copyText(hexDumpLines(hex.hex).join('\n'));
68
- }
69
- else if (rendered !== null && rendered.kind === 'text') {
70
- copyText(rendered.text);
71
- }
72
- else {
73
- copyText(draft.mode === 'value' ? draft.text : draft.mode === 'null' ? 'NULL' : 'DEFAULT');
74
- }
75
- };
76
- const editor = (extra) => (_jsx(TextArea, { mono: true, "aria-label": t(source.binary ? 'value.hexEditor' : 'value.editor', { column: column.name }), wrap: wrap || source.binary ? 'soft' : 'off', spellCheck: false, readOnly: !editable, placeholder: placeholder, value: draft.mode === 'value' ? draft.text : '', className: extra, onChange: (event) => change({ text: event.target.value, mode: 'value' }), onKeyDown: handleKeyDown }));
77
- const problemText = (found) => found.kind === 'json'
78
- ? t('value.invalidJson', { reason: found.reason })
79
- : t(found.problem === 'odd' ? 'value.invalidHexOdd' : 'value.invalidHexCharacters');
80
- const reading = (content, wrapped) => {
81
- if (content.kind === 'empty') {
82
- return _jsx(Faint, { children: placeholder });
83
- }
84
- if (content.kind === 'problem') {
85
- return _jsx(Faint, { children: problemText(content.problem) });
86
- }
87
- return (_jsxs(_Fragment, { children: [_jsx("pre", { className: clsx(READING, wrapped ? 'break-words whitespace-pre-wrap' : 'whitespace-pre'), children: content.text }), content.truncated && (_jsx("p", { className: "shrink-0 text-xs text-text-faint", children: t('value.dumpTruncated', { shown: formatNumber(content.truncated.shown), total: formatNumber(content.truncated.total) }) }))] }));
88
- };
89
- const body = () => {
90
- if (view === 'text') {
91
- return editor('min-h-0 flex-1');
92
- }
93
- if (view === 'hex' && editable) {
94
- return (_jsxs(_Fragment, { children: [draft.mode === 'value' && rendered !== null && reading(rendered, false), editor(draft.mode === 'value' ? 'flex-none' : 'min-h-0 flex-1')] }));
95
- }
96
- return rendered === null ? null : reading(rendered, wrap && WRAPPING_VIEWS.has(view));
97
- };
98
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex h-10 shrink-0 items-center gap-2 px-2", children: [views.length > 1 && (_jsx(Segmented, { value: view, onValueChange: (id) => setChoice(id), label: t('value.views'), options: views.map((id) => ({ id, label: t(`value.view.${id}`) })) })), _jsxs("div", { className: "ml-auto flex items-center gap-1", children: [WRAPPING_VIEWS.has(view) && (_jsx(IconButton, { icon: WrapText, size: "sm", label: t('value.wrap'), "aria-pressed": wrap, active: wrap, onClick: () => setWrap(!wrap) })), _jsx(IconButton, { icon: Copy, size: "sm", label: t('value.copy'), onClick: copy })] })] }), _jsx("div", { className: "flex min-h-0 flex-1 flex-col gap-2 px-2 pb-2", children: body() }), shownProblem !== null && _jsx(FormError, { className: "shrink-0 px-3 pb-2 text-xs", children: problemText(shownProblem) }), editable && (_jsxs("div", { className: "flex shrink-0 items-center gap-1.5 border-t border-border p-2", children: [_jsxs(Menu.Root, { children: [_jsx(IconButton, { icon: Ellipsis, size: "sm", label: t('value.more'), render: _jsx(Menu.Trigger, {}) }), _jsxs(Menu.Popup, { children: [column.nullable !== false && (_jsx(Menu.Item, { disabled: draft.mode === 'null', onClick: () => change({ text: '', mode: 'null' }), children: t('value.setNull') })), _jsx(Menu.Item, { disabled: draft.mode === 'default', onClick: () => change({ text: '', mode: 'default' }), children: t('value.setDefault') })] })] }), _jsxs("span", { className: "ml-auto flex items-center gap-1.5", children: [_jsx(Button, { size: "xs", disabled: !dirty, onClick: () => change(source.original), children: t('value.revert') }), _jsx(Tooltip, { label: t('value.applyHint'), kbd: KEY_SHORTCUTS.modEnter, children: _jsx(Button, { size: "xs", variant: "primary", disabled: !dirty || onCommit === undefined, onClick: apply, children: t('value.apply') }) })] })] })), _jsx("footer", { className: "flex h-8 shrink-0 items-center border-t border-border px-3 text-xs text-text-muted tabular-nums", children: size !== null &&
99
- (size.unit === 'bytes' ? formatBytes(size.count) : t('value.characters', { count: size.count, formatted: formatNumber(size.count) })) })] }));
100
- }
101
- function Faint({ children }) {
102
- return _jsx("div", { className: "grid min-h-0 flex-1 place-items-center font-mono text-code text-text-faint", children: children });
103
- }
@@ -3,10 +3,10 @@ import { useEffect, useMemo, useRef, useState } from 'react';
3
3
  import clsx from 'clsx';
4
4
  import { PencilRuler, Settings2, SquareTerminal, Table, TableProperties } from 'lucide-react';
5
5
  import { useTranslation } from 'react-i18next';
6
- import { Button, ColumnResizeHandle, Dialog, EmptyState, Icon, IconButton, PromptDialog, Segmented, Tabs, isApplePlatform, matchesShortcut, shortcut, useColumnResize } from '@adecore/ui';
6
+ import { Button, CloseButton, ColumnResizeHandle, Dialog, EmptyState, Icon, IconButton, PromptDialog, Segmented, Tabs, isApplePlatform, matchesShortcut, shortcut, useColumnResize } from '@adecore/ui';
7
7
  import { formatNumber } from '@adecore/ui/format';
8
8
  import { DatabaseProvider } from '../DatabaseProvider.js';
9
- import { useDatabaseAction, useDatabaseClient, useDatabaseFiles, useDatabaseStorage, useNumberNotation } from '../client-context.js';
9
+ import { useDatabaseAction, useDatabaseStorage } from '../client-context.js';
10
10
  import { ConnectionManager } from '../connections/ConnectionManager.js';
11
11
  import { QueryConsole } from '../console/QueryConsole.js';
12
12
  import { DatabaseExplorer } from '../explorer/DatabaseExplorer.js';
@@ -23,11 +23,8 @@ const MIN_WIDTH = 200;
23
23
  */
24
24
  export function DatabaseWorkbench({ connections, onConnectionsChange, className, ref }) {
25
25
  const { t } = useTranslation('database');
26
- const client = useDatabaseClient();
27
26
  const outer = useDatabaseAction();
28
27
  const storage = useDatabaseStorage();
29
- const files = useDatabaseFiles();
30
- const numberNotation = useNumberNotation();
31
28
  const column = useRef(null);
32
29
  const [state, setState] = useState(() => parseState(storage?.get(STORAGE_KEY) ?? null));
33
30
  const [selection, setSelection] = useState(null);
@@ -160,7 +157,7 @@ export function DatabaseWorkbench({ connections, onConnectionsChange, className,
160
157
  const canOpenConsole = consoleContext() !== null;
161
158
  const closingTab = state.tabs.find((tab) => tab.id === closing);
162
159
  const closingTable = closingTab?.kind === 'table' ? closingTab.ref.table : '';
163
- return (_jsx(DatabaseProvider, { client: client, onAction: onAction, storage: storage, files: files, numberNotation: numberNotation, children: _jsxs("div", { ref: ref, className: clsx('flex h-full min-h-0 min-w-0 bg-surface text-text', className), onKeyDown: onKeyDown, children: [_jsxs("aside", { ref: column, className: "relative flex shrink-0 flex-col border-r border-border", style: { width }, children: [_jsxs("header", { className: "flex h-9 shrink-0 items-center justify-between border-b border-border pr-2 pl-3", children: [_jsx("h2", { className: "text-sm font-semibold text-text", children: t('workbench.title') }), canManage && (_jsx(IconButton, { icon: Settings2, size: "sm", label: t('workbench.manage'), onClick: () => {
160
+ return (_jsx(DatabaseProvider, { onAction: onAction, children: _jsxs("div", { ref: ref, className: clsx('flex h-full min-h-0 min-w-0 bg-surface text-text', className), onKeyDown: onKeyDown, children: [_jsxs("aside", { ref: column, className: "relative flex shrink-0 flex-col border-r border-border", style: { width }, children: [_jsxs("header", { className: "flex h-9 shrink-0 items-center justify-between border-b border-border pr-2 pl-3", children: [_jsx("h2", { className: "text-sm font-semibold text-text", children: t('workbench.title') }), canManage && (_jsx(IconButton, { icon: Settings2, size: "sm", label: t('workbench.manage'), onClick: () => {
164
161
  setManaged(null);
165
162
  setManaging(true);
166
163
  } }))] }), _jsx(DatabaseExplorer, { connections: connections, value: selection, onValueChange: setSelection, className: "min-h-0 flex-1" }), _jsx(ColumnResizeHandle, { from: "left", onPointerDown: startResize })] }), _jsxs(Tabs.Root, { value: active?.id ?? null, onValueChange: (id) => setState((current) => focusTab(current, String(id))), className: "flex min-h-0 min-w-0 flex-1 flex-col", children: [_jsx(Tabs.List, { "aria-label": t('workbench.tabs'), className: "px-2", end: _jsxs(_Fragment, { children: [active?.kind === 'table' && (_jsx(Segmented, { label: t('workbench.views'), value: active.view, onValueChange: (view) => setState((current) => setTableView(current, active.id, view)), options: [
@@ -175,5 +172,5 @@ export function DatabaseWorkbench({ connections, onConnectionsChange, className,
175
172
  if (!open) {
176
173
  setClosing(null);
177
174
  }
178
- } }), onConnectionsChange !== undefined && (_jsx(Dialog.Root, { open: managing, onOpenChange: setManaging, children: _jsxs(Dialog.Popup, { className: "flex h-[560px] w-[900px] flex-col overflow-hidden", children: [_jsx("div", { className: "border-b border-border px-4 py-3", children: _jsx(Dialog.Title, { children: t('workbench.connections') }) }), _jsx(ConnectionManager, { value: connections, onValueChange: onConnectionsChange, selected: managed, onSelectedChange: setManaged, className: "min-h-0 flex-1" }), _jsx(Dialog.Footer, { children: _jsx(Dialog.Close, { render: _jsx(Button, { variant: "secondary" }), children: t('workbench.close') }) })] }) }))] }) }));
175
+ } }), onConnectionsChange !== undefined && (_jsx(Dialog.Root, { open: managing, onOpenChange: setManaging, children: _jsxs(Dialog.Popup, { className: "flex h-[640px] w-[800px] flex-col overflow-hidden", children: [_jsxs("div", { className: "flex items-center gap-4 border-b border-border px-5 py-4", children: [_jsx(Dialog.Title, { className: "grow", children: t('workbench.connections') }), _jsx(CloseButton, { label: t('workbench.close'), dialog: true })] }), _jsx(ConnectionManager, { value: connections, onValueChange: onConnectionsChange, selected: managed, onSelectedChange: setManaged, className: "min-h-0 flex-1" }), _jsx("div", { className: "flex shrink-0 justify-end border-t border-border px-5 py-3", children: _jsx(Dialog.Close, { render: _jsx(Button, { variant: "secondary" }), children: t('workbench.close') }) })] }) }))] }) }));
179
176
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/database",
3
- "version": "0.17.0",
3
+ "version": "0.18.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": "FSL-1.1-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.17.0",
65
- "@adecore/database-linux-arm64": "0.17.0",
66
- "@adecore/database-linux-x64": "0.17.0",
67
- "@adecore/database-win32-x64": "0.17.0"
64
+ "@adecore/database-darwin-arm64": "0.18.1",
65
+ "@adecore/database-linux-arm64": "0.18.1",
66
+ "@adecore/database-linux-x64": "0.18.1",
67
+ "@adecore/database-win32-x64": "0.18.1"
68
68
  },
69
69
  "peerDependencies": {
70
- "@adecore/ui": "^0.17.0",
70
+ "@adecore/ui": "^0.18.1",
71
71
  "i18next": "^26.4.2",
72
72
  "react": "^19.3.0",
73
73
  "react-dom": "^19.3.0",
@@ -1,15 +1,18 @@
1
- import { useMemo, useState, type ReactNode } from 'react';
1
+ import { use, useMemo, useState, type ReactNode } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
- import type { DatabaseAction, DatabaseFiles, DatabaseStorage } from './actions.ts';
3
+ import type { DatabaseAction, DatabaseFiles, DatabaseNotice, DatabaseStorage } from './actions.ts';
4
4
  import type { DatabaseClient } from './client/types.ts';
5
5
  import { DatabaseContext } from './client-context.ts';
6
6
  import type { NumberNotation } from './grid/display.ts';
7
7
  import { addDatabaseResources } from './locales.ts';
8
8
 
9
9
  export interface DatabaseProviderProps {
10
- client: DatabaseClient;
10
+ /* Required on the outermost provider. A provider inside another takes every prop it leaves out from the one above. */
11
+ client?: DatabaseClient;
11
12
  /* Where a table, a console or the designer a view asks for opens. Without it the views offer none of those. */
12
13
  onAction?(action: DatabaseAction): void;
14
+ /* Where a view's passing news goes, such as an export that finished. Without it each view shows its own above its content. */
15
+ onNotice?(notice: DatabaseNotice): void;
13
16
  storage?: DatabaseStorage;
14
17
  files?: DatabaseFiles;
15
18
  /*
@@ -22,8 +25,9 @@ export interface DatabaseProviderProps {
22
25
  }
23
26
 
24
27
  /* Hands every view below it the client and the app's hooks, and adds the package's words to the i18next the app's `UIProvider` was given. Mount it inside that provider. */
25
- export function DatabaseProvider({ client, onAction, storage, files, numberNotation = 'database', children }: DatabaseProviderProps) {
28
+ export function DatabaseProvider({ client, onAction, onNotice, storage, files, numberNotation, children }: DatabaseProviderProps) {
26
29
  const { i18n } = useTranslation();
30
+ const outer = use(DatabaseContext);
27
31
  const [added, setAdded] = useState<typeof i18n | null>(null);
28
32
  // In render, as `UIProvider` does, so the first view already reads its words.
29
33
  if (added !== i18n) {
@@ -31,7 +35,22 @@ export function DatabaseProvider({ client, onAction, storage, files, numberNotat
31
35
  setAdded(i18n);
32
36
  }
33
37
 
34
- const value = useMemo(() => ({ client, onAction, storage, files, numberNotation }), [client, onAction, storage, files, numberNotation]);
38
+ const ownClient = client ?? outer?.client;
39
+ const ownAction = onAction ?? outer?.onAction;
40
+ const ownNotice = onNotice ?? outer?.onNotice;
41
+ const ownStorage = storage ?? outer?.storage;
42
+ const ownFiles = files ?? outer?.files;
43
+ const ownNotation = numberNotation ?? outer?.numberNotation ?? 'database';
44
+ const value = useMemo(
45
+ () =>
46
+ ownClient === undefined
47
+ ? null
48
+ : { client: ownClient, onAction: ownAction, onNotice: ownNotice, storage: ownStorage, files: ownFiles, numberNotation: ownNotation },
49
+ [ownClient, ownAction, ownNotice, ownStorage, ownFiles, ownNotation]
50
+ );
51
+ if (value === null) {
52
+ throw new Error('A DatabaseProvider needs a client, or a DatabaseProvider above it to take one from.');
53
+ }
35
54
 
36
55
  return <DatabaseContext value={value}>{children}</DatabaseContext>;
37
56
  }
@@ -0,0 +1,15 @@
1
+ import clsx from 'clsx';
2
+ import { KeyRound } from 'lucide-react';
3
+ import { Icon } from '@adecore/ui';
4
+ import type { ColumnKeys } from './column-keys.ts';
5
+
6
+ /*
7
+ * The key of a column, yellow on a primary key and blue on a foreign key, the same in the tree and the
8
+ * grid. A column that is both shows the primary key, since that is what names its row; the tooltip says both.
9
+ */
10
+ export function KeyIcon({ primaryKey, foreignKey, size }: ColumnKeys & { size: 12 | 14 }) {
11
+ if (!primaryKey && !foreignKey) {
12
+ return null;
13
+ }
14
+ return <Icon icon={KeyRound} size={size} className={clsx('shrink-0', primaryKey ? 'text-(--file-icon-yellow)' : 'text-(--file-icon-blue)')} />;
15
+ }
package/src/actions.ts CHANGED
@@ -1,18 +1,44 @@
1
1
  import type { TableRef } from './client/types.ts';
2
+ import type { TableKind } from './protocol/index.ts';
2
3
 
3
4
  /*
4
5
  * Something a view asks the app to open. The views draw no tabs and no windows of their own: the app
5
6
  * decides where a table, a console or the designer lands, such as a tab beside its file tabs.
6
7
  */
7
8
  export type DatabaseAction =
8
- /* `where` opens the table filtered, as a jump along a foreign key does. */
9
- | { readonly kind: 'open-table'; readonly ref: TableRef; readonly view: 'data' | 'structure'; readonly where?: string }
9
+ | {
10
+ readonly kind: 'open-table';
11
+ readonly ref: TableRef;
12
+ readonly view: 'data' | 'structure';
13
+ /* Opens the table filtered, as a jump along a foreign key does. */
14
+ readonly where?: string;
15
+ /* Whether `ref` is a table or a view, where the sender knows it. */
16
+ readonly tableKind?: TableKind;
17
+ /*
18
+ * Only from an explorer that opens on a click: `true` for that click, a look a person may move on from, and
19
+ * `false` for a double click or Enter, which mean to keep the table open.
20
+ */
21
+ readonly preview?: boolean;
22
+ }
10
23
  | { readonly kind: 'open-console'; readonly connectionId: string; readonly schema?: string; readonly sql?: string }
11
24
  | { readonly kind: 'new-table'; readonly connectionId: string; readonly schema: string }
12
25
  | { readonly kind: 'edit-table'; readonly ref: TableRef }
13
26
  /* The app opens its connection manager on this connection. */
14
27
  | { readonly kind: 'manage-connection'; readonly connectionId: string };
15
28
 
29
+ /*
30
+ * Something a view tells a person that passes, such as an export that finished or failed, for the app to show
31
+ * where it shows such news. What stays part of a view, such as a conflict or a read-only reason, is no notice.
32
+ */
33
+ export interface DatabaseNotice {
34
+ readonly tone: 'success' | 'error';
35
+ /* What happened in a few words, such as "Exported 1,884 rows." or "Export failed". */
36
+ readonly title: string;
37
+ /* Why it failed, as the server or the file system said it. */
38
+ readonly description?: string;
39
+ readonly action?: { readonly label: string; run(): void };
40
+ }
41
+
16
42
  /* What is selected in the explorer: a connection, a schema in it, or a table in that schema. */
17
43
  export interface ExplorerSelection {
18
44
  readonly connectionId: string;
@@ -1,11 +1,12 @@
1
1
  import { createContext, use } from 'react';
2
- import type { DatabaseAction, DatabaseFiles, DatabaseStorage } from './actions.ts';
2
+ import type { DatabaseAction, DatabaseFiles, DatabaseNotice, DatabaseStorage } from './actions.ts';
3
3
  import type { DatabaseClient } from './client/types.ts';
4
4
  import type { NumberNotation } from './grid/display.ts';
5
5
 
6
6
  export interface DatabaseContextValue {
7
7
  readonly client: DatabaseClient;
8
8
  readonly onAction: ((action: DatabaseAction) => void) | undefined;
9
+ readonly onNotice: ((notice: DatabaseNotice) => void) | undefined;
9
10
  readonly storage: DatabaseStorage | undefined;
10
11
  readonly files: DatabaseFiles | undefined;
11
12
  readonly numberNotation: NumberNotation;
@@ -26,6 +27,9 @@ export const useDatabaseClient = (): DatabaseClient => useDatabaseContext().clie
26
27
  /* Hands an action to the app, or `undefined` when the app takes none, so a view can leave out what would go nowhere. */
27
28
  export const useDatabaseAction = (): ((action: DatabaseAction) => void) | undefined => useDatabaseContext().onAction;
28
29
 
30
+ /* Hands a notice to the app, or `undefined` when the app takes none and the view shows it itself. */
31
+ export const useDatabaseNotice = (): ((notice: DatabaseNotice) => void) | undefined => useDatabaseContext().onNotice;
32
+
29
33
  export const useDatabaseStorage = (): DatabaseStorage | undefined => useDatabaseContext().storage;
30
34
 
31
35
  /* The app's file dialogs; export and import stay hidden without them. */
@@ -0,0 +1,2 @@
1
+ /* Code a person reads as code (values, statements, DDL), at the size the app gives its code through `--code-font-size` and `--code-line-height`. */
2
+ export const CODE_TEXT = 'font-mono text-(length:--code-font-size) leading-(--code-line-height)';
@@ -0,0 +1,8 @@
1
+ export interface ColumnKeys {
2
+ readonly primaryKey: boolean;
3
+ readonly foreignKey: boolean;
4
+ }
5
+
6
+ /* The words that name what a key column is, for its tooltip. */
7
+ export const keyLabelOf = ({ primaryKey, foreignKey }: ColumnKeys): 'explorer.primaryAndForeignKey' | 'explorer.primaryKey' | 'explorer.foreignKey' =>
8
+ primaryKey && foreignKey ? 'explorer.primaryAndForeignKey' : primaryKey ? 'explorer.primaryKey' : 'explorer.foreignKey';
@@ -1,14 +1,15 @@
1
- import { useState, type Ref } from 'react';
1
+ import { useState, type ReactNode, type Ref } from 'react';
2
+ import clsx from 'clsx';
2
3
  import { CircleAlert, CircleCheck, Container, Database, Plus, Trash2, Unplug } from 'lucide-react';
3
4
  import { useTranslation } from 'react-i18next';
4
- import { Banner, Button, EmptyState, Icon, Menu, messageOf, PromptDialog, Spinner } from '@adecore/ui';
5
- import { DetailHeader, MasterDetail, MasterItem } from '@adecore/ui/settings';
5
+ import { Banner, Button, CloseButton, EmptyState, Icon, Menu, messageOf, PromptDialog, Spinner } from '@adecore/ui';
6
+ import { DetailHeader, MasterItem } from '@adecore/ui/settings';
6
7
  import { useDatabaseClient } from '../client-context.ts';
7
8
  import type { Connection } from '../client/types.ts';
8
9
  import type { ConnectionConfig, DockerContainer, Engine, ServerInfo } from '../protocol/index.ts';
9
10
  import { defaultConfig, isValidConfig, targetOf } from './connection-config.ts';
10
11
  import { ConnectionForm } from './ConnectionForm.tsx';
11
- import { composeNameOf, connectionFromContainer, isMysqlContainer } from './docker.ts';
12
+ import { connectionFromContainer, containerTitle, isMysqlContainer } from './docker.ts';
12
13
  import { useDockerContainers } from './use-docker-containers.ts';
13
14
  import { ENGINE_ICONS } from './engine-icons.ts';
14
15
  import { EngineIcon } from './EngineIcon.tsx';
@@ -24,20 +25,33 @@ export interface ConnectionManagerProps {
24
25
  onSelectedChange?(id: string | null): void;
25
26
  /* Opens the app's file dialog for a database file or an SSH identity file; without it the path is typed. See `ConnectionForm`. */
26
27
  onBrowse?(purpose: 'database' | 'identity'): Promise<string | null>;
28
+ /* The app's own settings of the picked connection, drawn after the form's fields and in their rhythm, such as a horizontal `Field`. */
29
+ renderFields?(connection: Connection): ReactNode;
27
30
  className?: string;
28
31
  ref?: Ref<HTMLDivElement>;
29
32
  }
30
33
 
31
- type TestOutcome = { readonly config: ConnectionConfig; readonly info: ServerInfo } | { readonly config: ConnectionConfig; readonly error: string };
34
+ /* What the server answered a test, for the connection and the config that were tested. */
35
+ type TestOutcome = { readonly connectionId: string; readonly config: ConnectionConfig } & ({ readonly info: ServerInfo } | { readonly error: string });
32
36
 
33
- /* The saved connections beside the form of the one picked, for a settings pane or a view of its own. */
34
- export function ConnectionManager({ value, onValueChange, selected, onSelectedChange, onBrowse, className, ref }: ConnectionManagerProps) {
37
+ /*
38
+ * The saved connections beside the form of the one picked, for a dialog, a settings pane or a view of its own. The list
39
+ * scrolls on its own with New connection under it, and the detail scrolls beside it; in a narrow window they stack.
40
+ */
41
+ export function ConnectionManager({ value, onValueChange, selected, onSelectedChange, onBrowse, renderFields, className, ref }: ConnectionManagerProps) {
35
42
  const { t } = useTranslation('database');
36
43
  const [own, setOwn] = useState<string | null>(null);
37
44
  const [deleting, setDeleting] = useState(false);
45
+ const [outcome, setOutcome] = useState<TestOutcome | null>(null);
38
46
  const client = useDatabaseClient();
39
47
  const pickedId = selected === undefined ? own : selected;
40
48
  const current = value.find((connection) => connection.id === pickedId) ?? value[0] ?? null;
49
+ // The answer of one connection is gone once another is picked, so coming back does not bring it back.
50
+ if (outcome !== null && outcome.connectionId !== current?.id) {
51
+ setOutcome(null);
52
+ }
53
+ // An answer to an older config is never shown for a newer one.
54
+ const shown = outcome !== null && current !== null && outcome.config === current.config ? outcome : null;
41
55
 
42
56
  const select = (id: string | null): void => {
43
57
  if (selected === undefined) {
@@ -71,41 +85,46 @@ export function ConnectionManager({ value, onValueChange, selected, onSelectedCh
71
85
  };
72
86
 
73
87
  const list = (
74
- <>
75
- <Menu.Root>
76
- <Menu.Trigger render={<Button variant="secondary" size="sm" className="mb-2" />}>
77
- <Icon icon={Plus} size={14} />
78
- {t('connections.new')}
79
- </Menu.Trigger>
80
- <Menu.Popup>
81
- {ENGINES.map((engine) => (
82
- <Menu.Item key={engine} onClick={() => add(engine)}>
83
- <Icon icon={ENGINE_ICONS[engine]} size={14} />
84
- {t(`engine.${engine}`)}
85
- </Menu.Item>
86
- ))}
87
- <Menu.Separator />
88
- <Menu.SubmenuRoot>
89
- <Menu.SubmenuTrigger>
90
- <Icon icon={Container} size={14} />
91
- {t('connections.fromDocker.label')}
92
- </Menu.SubmenuTrigger>
93
- <Menu.Popup>
94
- <DockerMenuItems onPick={addFromContainer} />
95
- </Menu.Popup>
96
- </Menu.SubmenuRoot>
97
- </Menu.Popup>
98
- </Menu.Root>
99
- {value.map((connection) => (
100
- <MasterItem key={connection.id} selected={connection.id === current?.id} onSelect={() => select(connection.id)}>
101
- <EngineIcon engine={connection.config.engine} size={16} className="shrink-0 text-text-muted" />
102
- <span className="flex min-w-0 flex-col">
103
- <span className="truncate">{connection.name || t('connections.untitled')}</span>
104
- <span className="truncate text-xs text-text-faint">{targetOf(connection)}</span>
105
- </span>
106
- </MasterItem>
107
- ))}
108
- </>
88
+ <div className="flex w-56 shrink-0 flex-col border-r border-border max-[640px]:max-h-60 max-[640px]:w-auto max-[640px]:border-r-0 max-[640px]:border-b">
89
+ <nav aria-label={t('connections.list')} className="flex min-h-0 grow flex-col gap-0.5 overflow-y-auto p-2">
90
+ {value.length === 0 && <p className="px-2 py-1.5 text-xs text-text-faint">{t('connections.empty')}</p>}
91
+ {value.map((connection) => (
92
+ <MasterItem key={connection.id} selected={connection.id === current?.id} onSelect={() => select(connection.id)}>
93
+ <EngineIcon engine={connection.config.engine} size={16} className="shrink-0 text-text-muted" />
94
+ <span className="flex min-w-0 flex-col">
95
+ <span className="truncate">{connection.name || t('connections.untitled')}</span>
96
+ <span className="truncate text-xs text-text-faint">{targetOf(connection)}</span>
97
+ </span>
98
+ </MasterItem>
99
+ ))}
100
+ </nav>
101
+ <div className="flex shrink-0 flex-col gap-1 border-t border-border p-2">
102
+ <Menu.Root>
103
+ <Menu.Trigger render={<Button variant="ghost" size="sm" className="justify-start" />}>
104
+ <Icon icon={Plus} size={14} />
105
+ {t('connections.new')}
106
+ </Menu.Trigger>
107
+ <Menu.Popup>
108
+ {ENGINES.map((engine) => (
109
+ <Menu.Item key={engine} onClick={() => add(engine)}>
110
+ <Icon icon={ENGINE_ICONS[engine]} size={14} />
111
+ {t(`engine.${engine}`)}
112
+ </Menu.Item>
113
+ ))}
114
+ <Menu.Separator />
115
+ <Menu.SubmenuRoot>
116
+ <Menu.SubmenuTrigger>
117
+ <Icon icon={Container} size={14} />
118
+ {t('connections.fromDocker.label')}
119
+ </Menu.SubmenuTrigger>
120
+ <Menu.Popup>
121
+ <DockerMenuItems onPick={addFromContainer} />
122
+ </Menu.Popup>
123
+ </Menu.SubmenuRoot>
124
+ </Menu.Popup>
125
+ </Menu.Root>
126
+ </div>
127
+ </div>
109
128
  );
110
129
 
111
130
  const detail =
@@ -113,6 +132,20 @@ export function ConnectionManager({ value, onValueChange, selected, onSelectedCh
113
132
  <EmptyState icon={Database}>{t('connections.empty')}</EmptyState>
114
133
  ) : (
115
134
  <>
135
+ {shown !== null && (
136
+ <Banner
137
+ icon={'info' in shown ? CircleCheck : CircleAlert}
138
+ tone={'info' in shown ? 'neutral' : 'error'}
139
+ className="sticky top-0 z-10"
140
+ message={
141
+ 'info' in shown
142
+ ? t('connections.test.success', { server: `${t(`engine.flavor.${shown.info.flavor}`)} ${shown.info.version}` })
143
+ : shown.error
144
+ }
145
+ >
146
+ <CloseButton size="sm" label={t('connections.test.dismiss')} onClick={() => setOutcome(null)} />
147
+ </Banner>
148
+ )}
116
149
  <DetailHeader
117
150
  mark={<EngineIcon engine={current.config.engine} size={20} className="mt-0.5 shrink-0 text-text-muted" />}
118
151
  title={current.name || t('connections.untitled')}
@@ -124,8 +157,11 @@ export function ConnectionManager({ value, onValueChange, selected, onSelectedCh
124
157
  </Button>
125
158
  }
126
159
  />
127
- <ConnectionForm value={current} onValueChange={change} onBrowse={onBrowse} />
128
- <ConnectionTest key={current.id} connection={current} />
160
+ <div className="flex flex-col gap-4">
161
+ <ConnectionForm value={current} onValueChange={change} onBrowse={onBrowse} />
162
+ {renderFields?.(current)}
163
+ </div>
164
+ <ConnectionTest key={current.id} connection={current} onOutcome={setOutcome} />
129
165
  <PromptDialog
130
166
  open={deleting}
131
167
  danger
@@ -141,7 +177,14 @@ export function ConnectionManager({ value, onValueChange, selected, onSelectedCh
141
177
  </>
142
178
  );
143
179
 
144
- return <MasterDetail ref={ref} className={className} list={list} listWidth={280} listLabel={t('connections.list')} detail={detail} />;
180
+ return (
181
+ <div ref={ref} className={clsx('flex min-h-0 min-w-0 grow max-[640px]:flex-col', className)}>
182
+ {list}
183
+ <div className="min-h-0 min-w-0 grow overflow-y-auto">
184
+ <div className="flex min-w-0 flex-col gap-6 px-5 py-4">{detail}</div>
185
+ </div>
186
+ </div>
187
+ );
145
188
  }
146
189
 
147
190
  /* The containers Docker is running as menu items. The submenu mounts this when it opens, so Docker is asked only then. */
@@ -175,50 +218,37 @@ function DockerMenuItems({ onPick }: { onPick(container: DockerContainer): void
175
218
  return containers.map((container) => (
176
219
  <Menu.Item key={container.id} onClick={() => onPick(container)}>
177
220
  <span className="flex min-w-0 flex-col">
178
- <span className="truncate">{composeNameOf(container) ?? container.name}</span>
221
+ <span className="truncate">{containerTitle(container)}</span>
179
222
  <span className="truncate text-xs text-text-faint">{container.image}</span>
180
223
  </span>
181
224
  </Menu.Item>
182
225
  ));
183
226
  }
184
227
 
185
- /* The button that asks the server who it is, and what it answered; an answer to an older config is not shown. */
186
- function ConnectionTest({ connection }: { connection: Connection }) {
228
+ /* The button that asks the server who it is. The manager shows the answer at the top of the detail. */
229
+ function ConnectionTest({ connection, onOutcome }: { connection: Connection; onOutcome(outcome: TestOutcome): void }) {
187
230
  const { t } = useTranslation('database');
188
231
  const client = useDatabaseClient();
189
232
  const [busy, setBusy] = useState(false);
190
- const [outcome, setOutcome] = useState<TestOutcome | null>(null);
191
- const shown = outcome !== null && outcome.config === connection.config ? outcome : null;
192
233
 
193
234
  const test = async (): Promise<void> => {
194
235
  const config = connection.config;
195
236
  setBusy(true);
196
237
  try {
197
- setOutcome({ config, info: await client.test(config) });
238
+ onOutcome({ connectionId: connection.id, config, info: await client.test(config) });
198
239
  } catch (e) {
199
- setOutcome({ config, error: messageOf(e, t('connections.test.failed')) });
240
+ onOutcome({ connectionId: connection.id, config, error: messageOf(e, t('connections.test.failed')) });
200
241
  } finally {
201
242
  setBusy(false);
202
243
  }
203
244
  };
204
245
 
205
246
  return (
206
- <div className="flex flex-col gap-3">
207
- <div>
208
- <Button variant="secondary" disabled={busy || !isValidConfig(connection.config)} onClick={() => void test()}>
209
- {busy ? <Spinner size={14} /> : <Icon icon={Unplug} size={14} />}
210
- {t('connections.test.action')}
211
- </Button>
212
- </div>
213
- {shown !== null && 'info' in shown && (
214
- <Banner
215
- icon={CircleCheck}
216
- tone="neutral"
217
- className="w-full"
218
- message={t('connections.test.success', { server: `${t(`engine.flavor.${shown.info.flavor}`)} ${shown.info.version}` })}
219
- />
220
- )}
221
- {shown !== null && 'error' in shown && <Banner icon={CircleAlert} tone="error" className="w-full" message={shown.error} />}
247
+ <div>
248
+ <Button variant="secondary" disabled={busy || !isValidConfig(connection.config)} onClick={() => void test()}>
249
+ {busy ? <Spinner size={14} /> : <Icon icon={Unplug} size={14} />}
250
+ {t('connections.test.action')}
251
+ </Button>
222
252
  </div>
223
253
  );
224
254
  }
@@ -82,8 +82,10 @@ const mysqlProblems = (config: MysqlConnectionConfig): ConfigProblems => {
82
82
  return problems;
83
83
  };
84
84
 
85
+ /* What is wrong with a config, field by field: the codes the form shows. Empty for a config with nothing to fix. */
85
86
  export const configProblems = (config: ConnectionConfig): ConfigProblems => (config.engine === 'sqlite' ? sqliteProblems(config) : mysqlProblems(config));
86
87
 
88
+ /* Whether a connection can be saved, rather than a draft a person is still filling in. */
87
89
  export const isValidConfig = (config: ConnectionConfig): boolean => Object.keys(configProblems(config)).length === 0;
88
90
 
89
91
  const lastSegment = (path: string): string => {