@bakery-framework/plugin-db-explorer 2.0.0-alpha.5 → 2.0.0-alpha.6

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 (51) hide show
  1. package/package.json +4 -4
  2. package/src/access.ts +188 -0
  3. package/src/client/api.ts +261 -0
  4. package/src/client/bulk.ts +361 -0
  5. package/src/client/cell.ts +139 -0
  6. package/src/client/confirm.ts +201 -0
  7. package/src/client/csv-commit.ts +185 -0
  8. package/src/client/csv-map.ts +274 -0
  9. package/src/client/csv-model.ts +420 -0
  10. package/src/client/csv-pick.ts +54 -0
  11. package/src/client/csv-preview.ts +91 -0
  12. package/src/client/csv.ts +104 -0
  13. package/src/client/dom.ts +144 -0
  14. package/src/client/edit-session.ts +219 -0
  15. package/src/client/editors.ts +283 -0
  16. package/src/client/filter-builder.ts +198 -0
  17. package/src/client/fk.ts +242 -0
  18. package/src/client/grid-body.ts +103 -0
  19. package/src/client/grid-header.ts +65 -0
  20. package/src/client/grid-rowbar.ts +64 -0
  21. package/src/client/grid.ts +466 -0
  22. package/src/client/meta.ts +188 -0
  23. package/src/client/page.ts +332 -0
  24. package/src/client/panel.ts +296 -0
  25. package/src/client/relations.ts +205 -0
  26. package/src/client/save.ts +209 -0
  27. package/src/client/sidebar.ts +110 -0
  28. package/src/client/state.ts +218 -0
  29. package/src/client/statusbar.ts +130 -0
  30. package/src/client/structure.ts +231 -0
  31. package/src/client/tabs.ts +224 -0
  32. package/src/client/tabstrip.ts +127 -0
  33. package/src/client.ts +374 -160
  34. package/src/endpoints/common.ts +122 -0
  35. package/src/endpoints/graph.ts +0 -0
  36. package/src/endpoints/import.ts +89 -0
  37. package/src/endpoints/read.ts +173 -0
  38. package/src/endpoints/rows.ts +435 -0
  39. package/src/identity.ts +391 -0
  40. package/src/index.ts +40 -45
  41. package/src/policy.ts +45 -0
  42. package/src/preview.ts +53 -0
  43. package/src/setup.ts +64 -81
  44. package/src/shared/coerce.ts +399 -0
  45. package/src/shared/csv.ts +235 -0
  46. package/src/shared/filters.ts +200 -0
  47. package/src/shared/plan.ts +164 -0
  48. package/src/shell.ts +187 -0
  49. package/src/validate.ts +295 -0
  50. package/src/credential.ts +0 -26
  51. package/src/endpoints.ts +0 -48
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Open tables as tabs, with VS Code's preview semantics.
3
+ *
4
+ * **Pure — no DOM.** Every transition is a function from a state to a state, so
5
+ * the rules below are asserted in `tabs.test.ts` rather than inferred from a
6
+ * click handler. `tabstrip.ts` is the view over this.
7
+ *
8
+ * The behaviour is Supabase Studio's, which is VS Code's:
9
+ *
10
+ * - A **single click** opens a *preview* tab — rendered italic, and **replaced
11
+ * in place** by the next single click. Browsing twelve tables therefore
12
+ * leaves one tab open, not twelve, which is the whole point.
13
+ * - **Double-clicking**, or **editing**, makes it permanent. Investment is what
14
+ * promotes a tab; merely looking at it is not.
15
+ * - A table that is already open is *selected* rather than reopened, and its
16
+ * own view state — page, sort, filters — is left exactly as it was. That is
17
+ * the property that makes tabs worth having: switching back restores.
18
+ *
19
+ * There is at most one preview tab, which is what makes "replace the preview"
20
+ * unambiguous.
21
+ */
22
+
23
+ import type { ViewState } from './state'
24
+ import { decodeView, defaultView, encodeView } from './state'
25
+
26
+ export interface Tab {
27
+ view: ViewState
28
+ /** Italic, and replaceable by the next single click. */
29
+ preview: boolean
30
+ }
31
+
32
+ export interface TabsState {
33
+ tabs: Tab[]
34
+ /** Index into `tabs`, or `-1` for the new-tab page when nothing is open. */
35
+ active: number
36
+ }
37
+
38
+ export function createTabs(): TabsState {
39
+ return { tabs: [], active: -1 }
40
+ }
41
+
42
+ export function activeTab(state: TabsState): Tab | null {
43
+ return state.tabs[state.active] ?? null
44
+ }
45
+
46
+ export function activeView(state: TabsState): ViewState | null {
47
+ return activeTab(state)?.view ?? null
48
+ }
49
+
50
+ export function indexOfTable(state: TabsState, table: string): number {
51
+ return state.tabs.findIndex(tab => tab.view.table === table)
52
+ }
53
+
54
+ function previewIndex(state: TabsState): number {
55
+ return state.tabs.findIndex(tab => tab.preview)
56
+ }
57
+
58
+ /**
59
+ * Single click: open as preview.
60
+ *
61
+ * Three cases in order, and the order is the behaviour. An already-open table
62
+ * wins over everything — reopening it would throw away the page and filters the
63
+ * user left it on, which is precisely what tabs exist to preserve.
64
+ */
65
+ export function openPreview(state: TabsState, view: ViewState): TabsState {
66
+ const existing = indexOfTable(state, view.table)
67
+ if (existing >= 0) return { ...state, active: existing }
68
+
69
+ const slot = previewIndex(state)
70
+ const tab: Tab = { view, preview: true }
71
+ if (slot >= 0) {
72
+ const tabs = [...state.tabs]
73
+ tabs[slot] = tab
74
+ return { tabs, active: slot }
75
+ }
76
+ return { tabs: [...state.tabs, tab], active: state.tabs.length }
77
+ }
78
+
79
+ /**
80
+ * Double click, or any explicit "open and keep": a permanent tab.
81
+ *
82
+ * An already-open preview tab for the same table is promoted where it stands
83
+ * rather than moved, so the strip does not reorder under the cursor that just
84
+ * double-clicked it.
85
+ */
86
+ export function openPermanent(state: TabsState, view: ViewState): TabsState {
87
+ const existing = indexOfTable(state, view.table)
88
+ if (existing >= 0) {
89
+ const tabs = [...state.tabs]
90
+ tabs[existing] = { ...tabs[existing]!, preview: false }
91
+ return { tabs, active: existing }
92
+ }
93
+
94
+ const slot = previewIndex(state)
95
+ const tab: Tab = { view, preview: false }
96
+ if (slot >= 0) {
97
+ const tabs = [...state.tabs]
98
+ tabs[slot] = tab
99
+ return { tabs, active: slot }
100
+ }
101
+ return { tabs: [...state.tabs, tab], active: state.tabs.length }
102
+ }
103
+
104
+ export function selectTab(state: TabsState, index: number): TabsState {
105
+ if (index < 0 || index >= state.tabs.length) return state
106
+ return { ...state, active: index }
107
+ }
108
+
109
+ /**
110
+ * Close one tab.
111
+ *
112
+ * Closing the active tab activates whatever slid into its slot — the tab to the
113
+ * right — and the last tab when there is nothing to the right. Closing anything
114
+ * left of the active one shifts the active index down so the *same* tab stays
115
+ * selected, which is the bug the naive version has.
116
+ */
117
+ export function closeTab(state: TabsState, index: number): TabsState {
118
+ if (index < 0 || index >= state.tabs.length) return state
119
+ const tabs = state.tabs.filter((_tab, at) => at !== index)
120
+ if (!tabs.length) return { tabs, active: -1 }
121
+
122
+ let active = state.active
123
+ if (index < state.active) active = state.active - 1
124
+ else if (index === state.active) active = Math.min(index, tabs.length - 1)
125
+ return { tabs, active }
126
+ }
127
+
128
+ /** What a real edit does to a preview tab. Idempotent, and a no-op when none. */
129
+ export function promoteActive(state: TabsState): TabsState {
130
+ const tab = activeTab(state)
131
+ if (!tab?.preview) return state
132
+ const tabs = [...state.tabs]
133
+ tabs[state.active] = { ...tab, preview: false }
134
+ return { ...state, tabs }
135
+ }
136
+
137
+ /**
138
+ * A new view for the active tab, keeping its preview flag.
139
+ *
140
+ * Paging and sorting inside a preview tab deliberately do **not** promote it:
141
+ * looking harder at a table is still looking. Only `promoteActive` promotes.
142
+ */
143
+ export function replaceActiveView(
144
+ state: TabsState,
145
+ view: ViewState,
146
+ ): TabsState {
147
+ const tab = activeTab(state)
148
+ if (!tab) return openPermanent(state, view)
149
+ const tabs = [...state.tabs]
150
+ tabs[state.active] = { ...tab, view }
151
+ return { ...state, tabs }
152
+ }
153
+
154
+ /**
155
+ * Drop tabs whose table no longer exists.
156
+ *
157
+ * A link can name anything, and a schema can change between two visits. A tab
158
+ * pointing at a table that is gone would render "Pick a table" forever with no
159
+ * way to tell why.
160
+ */
161
+ export function pruneTabs(
162
+ state: TabsState,
163
+ known: ReadonlySet<string>,
164
+ ): TabsState {
165
+ const keep = state.tabs.filter(tab => known.has(tab.view.table))
166
+ if (keep.length === state.tabs.length) return state
167
+ const wanted = activeTab(state)
168
+ const active = wanted ? keep.indexOf(wanted) : -1
169
+ return { tabs: keep, active: active >= 0 ? active : keep.length - 1 }
170
+ }
171
+
172
+ /**
173
+ * The whole strip in a hash.
174
+ *
175
+ * `x` repeats, one per tab, each holding a whole encoded view — `URLSearchParams`
176
+ * escapes the inner `&` on the way in and unescapes it on the way out, so the
177
+ * nesting needs no encoding of its own. `a` is the active index and `w` the
178
+ * preview one; both are indexes rather than flags because there is at most one
179
+ * of each and an index is shorter than a marker on every tab.
180
+ */
181
+ export function encodeTabs(state: TabsState): string {
182
+ const params = new URLSearchParams()
183
+ for (const tab of state.tabs) params.append('x', encodeView(tab.view))
184
+ if (state.active >= 0) params.set('a', String(state.active))
185
+ const preview = previewIndex(state)
186
+ if (preview >= 0) params.set('w', String(preview))
187
+ return params.toString()
188
+ }
189
+
190
+ export function decodeTabs(hash: string): TabsState {
191
+ const params = new URLSearchParams(hash.replace(/^#/, ''))
192
+ const encoded = params.getAll('x')
193
+
194
+ // A pre-tabs link — `#t=parcels&p=2` — is one permanent tab. Those links are
195
+ // in chat logs and bookmarks, and silently rendering nothing for them would
196
+ // be the most visible possible regression.
197
+ if (!encoded.length) {
198
+ const single = decodeView(hash)
199
+ if (!single.table) return createTabs()
200
+ return { tabs: [{ view: single, preview: false }], active: 0 }
201
+ }
202
+
203
+ const tabs: Tab[] = encoded.map(part => ({
204
+ view: decodeView(part),
205
+ preview: false,
206
+ }))
207
+ const preview = readIndex(params.get('w'), tabs.length)
208
+ if (preview >= 0) tabs[preview]!.preview = true
209
+
210
+ const active = readIndex(params.get('a'), tabs.length)
211
+ return { tabs, active: active >= 0 ? active : tabs.length - 1 }
212
+ }
213
+
214
+ /** An index from a hand-editable URL, or `-1`. Never out of range. */
215
+ function readIndex(raw: string | null, length: number): number {
216
+ if (raw === null) return -1
217
+ const value = Number.parseInt(raw, 10)
218
+ return Number.isFinite(value) && value >= 0 && value < length ? value : -1
219
+ }
220
+
221
+ /** A fresh tab for a table, as the sidebar and the relations view make one. */
222
+ export function tabView(table: string): ViewState {
223
+ return defaultView(table)
224
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The tab strip, and the view switcher under it.
3
+ *
4
+ * Two rows, and **exactly two** — the table tabs, then Data / Structure /
5
+ * Relations for whichever table is active. Beekeeper sells itself on not having
6
+ * "tabs within tabs", and it is right: one level of nesting is navigable and
7
+ * two is a maze. Nothing below this line gets its own strip.
8
+ *
9
+ * All the state lives in `tabs.ts`; this only renders it and reports clicks.
10
+ */
11
+
12
+ import { box, button, each, el, on } from './dom'
13
+ import type { TableView } from './state'
14
+ import type { TabsState } from './tabs'
15
+
16
+ export interface TabStripContext {
17
+ tabs: TabsState
18
+ onSelect: (index: number) => void
19
+ /** Double-click on a preview tab: keep it. */
20
+ onPromote: (index: number) => void
21
+ onClose: (index: number) => void
22
+ /** The `+` button — back to the table picker. */
23
+ onNew: () => void
24
+ }
25
+
26
+ export function renderTabStrip(ctx: TabStripContext): HTMLElement {
27
+ const strip = box('tabstrip')
28
+ strip.setAttribute('role', 'tablist')
29
+ each(strip, ctx.tabs.tabs, (_tab, index) => tabNode(ctx, index))
30
+ strip.appendChild(
31
+ button('+', ctx.onNew, {
32
+ class: 'tab-new',
33
+ title: 'open another table',
34
+ attrs: { 'aria-label': 'open another table' },
35
+ }),
36
+ )
37
+ return strip
38
+ }
39
+
40
+ function tabNode(ctx: TabStripContext, index: number): HTMLElement {
41
+ const tab = ctx.tabs.tabs[index]!
42
+ const node = box('tab')
43
+ const active = index === ctx.tabs.active
44
+ node.classList.toggle('active', active)
45
+ // Italic is the whole visual language of a preview tab, borrowed from VS
46
+ // Code because that is where the user learned it.
47
+ node.classList.toggle('preview', tab.preview)
48
+ node.setAttribute('role', 'tab')
49
+ node.setAttribute('aria-selected', active ? 'true' : 'false')
50
+
51
+ const label = button(tab.view.table, () => ctx.onSelect(index), {
52
+ class: 'tab-label',
53
+ title: tab.preview ? `${tab.view.table} — preview` : tab.view.table,
54
+ })
55
+ on(label, 'dblclick', () => ctx.onPromote(index))
56
+ node.appendChild(label)
57
+ node.appendChild(closeButton(ctx, index, tab.view.table))
58
+
59
+ // Middle-click closes, which is what every browser and every editor has
60
+ // trained people to expect. `auxclick` rather than `mousedown` so a middle
61
+ // *drag* that ends elsewhere does not close anything.
62
+ on(node, 'auxclick', event => {
63
+ if ((event as MouseEvent).button !== 1) return
64
+ event.preventDefault()
65
+ ctx.onClose(index)
66
+ })
67
+ return node
68
+ }
69
+
70
+ function closeButton(
71
+ ctx: TabStripContext,
72
+ index: number,
73
+ table: string,
74
+ ): HTMLElement {
75
+ return button('×', () => ctx.onClose(index), {
76
+ class: 'tab-close',
77
+ title: `close ${table}`,
78
+ attrs: { 'aria-label': `close ${table}` },
79
+ })
80
+ }
81
+
82
+ // ------------------------------------------------------------ the view switch
83
+
84
+ const VIEWS: readonly { value: TableView; label: string }[] = [
85
+ { value: 'data', label: 'Data' },
86
+ { value: 'structure', label: 'Structure' },
87
+ { value: 'relations', label: 'Relations' },
88
+ ]
89
+
90
+ export interface ViewTabsContext {
91
+ current: TableView
92
+ onSelect: (view: TableView) => void
93
+ }
94
+
95
+ /**
96
+ * Data / Structure / Relations.
97
+ *
98
+ * Structure is separate from data rather than folded into a header panel,
99
+ * which is what DBeaver, DataGrip and Devart all do and what makes "what is
100
+ * this column" answerable without disturbing the grid you were reading.
101
+ */
102
+ export function renderViewTabs(ctx: ViewTabsContext): HTMLElement {
103
+ const strip = box('viewtabs')
104
+ strip.setAttribute('role', 'tablist')
105
+ each(strip, VIEWS, entry => {
106
+ const active = entry.value === ctx.current
107
+ const node = button(entry.label, () => ctx.onSelect(entry.value), {
108
+ class: active ? 'viewtab active' : 'viewtab',
109
+ attrs: { role: 'tab', 'aria-selected': active ? 'true' : 'false' },
110
+ })
111
+ return node
112
+ })
113
+ return strip
114
+ }
115
+
116
+ /** The `+` page: no table open, so say what to do rather than showing nothing. */
117
+ export function renderNewTabPage(): HTMLElement {
118
+ const node = box('newtab')
119
+ node.appendChild(el('h2', { text: 'No table open' }))
120
+ node.appendChild(
121
+ el('p', {
122
+ class: 'note',
123
+ text: 'Click a table to preview it. Double-click to keep it open.',
124
+ }),
125
+ )
126
+ return node
127
+ }