@antelopejs/dms-database 0.0.14 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +30 -5
  2. package/dist/db/models/query_history.model.d.ts +1 -0
  3. package/dist/db/models/query_history.model.js +6 -0
  4. package/dist/db/models/query_history.model.js.map +1 -1
  5. package/dist/db/tables/query_history.table.d.ts +3 -0
  6. package/dist/db/tables/query_history.table.js +12 -0
  7. package/dist/db/tables/query_history.table.js.map +1 -1
  8. package/dist/db/tables/saved_queries.table.d.ts +1 -0
  9. package/dist/db/tables/saved_queries.table.js +4 -0
  10. package/dist/db/tables/saved_queries.table.js.map +1 -1
  11. package/dist/i18n/messages.d.ts +39 -0
  12. package/dist/i18n/messages.js +64 -0
  13. package/dist/i18n/messages.js.map +1 -0
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +1 -7
  16. package/dist/index.js.map +1 -1
  17. package/dist/module.d.ts +11 -0
  18. package/dist/module.js +55 -0
  19. package/dist/module.js.map +1 -0
  20. package/dist/pages/data.d.ts +1 -1
  21. package/dist/pages/data.js +9 -6
  22. package/dist/pages/data.js.map +1 -1
  23. package/dist/pages/diagram.d.ts +9 -0
  24. package/dist/pages/diagram.js +45 -0
  25. package/dist/pages/diagram.js.map +1 -0
  26. package/dist/pages/index.d.ts +1 -0
  27. package/dist/pages/index.js +1 -0
  28. package/dist/pages/index.js.map +1 -1
  29. package/dist/pages/overview.d.ts +4 -1
  30. package/dist/pages/overview.js +113 -6
  31. package/dist/pages/overview.js.map +1 -1
  32. package/dist/pages/query.d.ts +1 -1
  33. package/dist/pages/query.js +22 -6
  34. package/dist/pages/query.js.map +1 -1
  35. package/dist/pages/schemas.d.ts +2 -1
  36. package/dist/pages/schemas.js +143 -6
  37. package/dist/pages/schemas.js.map +1 -1
  38. package/dist/routes/data.d.ts +8 -0
  39. package/dist/routes/data.js +84 -21
  40. package/dist/routes/data.js.map +1 -1
  41. package/dist/routes/index.d.ts +1 -0
  42. package/dist/routes/index.js +1 -0
  43. package/dist/routes/index.js.map +1 -1
  44. package/dist/routes/overview.d.ts +12 -0
  45. package/dist/routes/overview.js +55 -0
  46. package/dist/routes/overview.js.map +1 -0
  47. package/dist/routes/query.d.ts +5 -1
  48. package/dist/routes/query.js +162 -27
  49. package/dist/routes/query.js.map +1 -1
  50. package/dist/routes/tables.d.ts +15 -12
  51. package/dist/routes/tables.js +95 -55
  52. package/dist/routes/tables.js.map +1 -1
  53. package/dist/service/catalog.d.ts +3 -0
  54. package/dist/service/catalog.js +26 -0
  55. package/dist/service/catalog.js.map +1 -0
  56. package/dist/service/health.d.ts +3 -3
  57. package/dist/service/health.js +17 -14
  58. package/dist/service/health.js.map +1 -1
  59. package/dist/service/introspect.d.ts +3 -1
  60. package/dist/service/introspect.js +131 -43
  61. package/dist/service/introspect.js.map +1 -1
  62. package/dist/service/overview.d.ts +9 -0
  63. package/dist/service/overview.js +113 -0
  64. package/dist/service/overview.js.map +1 -0
  65. package/dist/service/queryInspection.d.ts +15 -0
  66. package/dist/service/queryInspection.js +107 -0
  67. package/dist/service/queryInspection.js.map +1 -0
  68. package/dist/service/references.d.ts +9 -0
  69. package/dist/service/references.js +52 -0
  70. package/dist/service/references.js.map +1 -0
  71. package/dist/service/schemaFilters.d.ts +26 -0
  72. package/dist/service/schemaFilters.js +42 -0
  73. package/dist/service/schemaFilters.js.map +1 -0
  74. package/dist/service/stagedSerialization.d.ts +2 -0
  75. package/dist/service/stagedSerialization.js +38 -10
  76. package/dist/service/stagedSerialization.js.map +1 -1
  77. package/dist/service/types.d.ts +11 -1
  78. package/dist/types/constants.d.ts +2 -0
  79. package/dist/types/constants.js +3 -1
  80. package/dist/types/constants.js.map +1 -1
  81. package/dist/utils/query.d.ts +0 -2
  82. package/dist/utils/query.js +0 -20
  83. package/dist/utils/query.js.map +1 -1
  84. package/frontend-vue/app/{composables → build/composables}/useDataBrowserGrid.ts +65 -50
  85. package/frontend-vue/app/build/composables/useDataBrowserTabs.ts +366 -0
  86. package/frontend-vue/app/build/composables/useDatabaseHealth.ts +50 -0
  87. package/frontend-vue/app/build/composables/useDatabaseSchemas.ts +140 -0
  88. package/frontend-vue/app/{composables → build/composables}/useDiagramPersistence.ts +47 -39
  89. package/frontend-vue/app/build/composables/useQueryRuntime.ts +217 -0
  90. package/frontend-vue/app/build/composables/useQueryStore.ts +162 -0
  91. package/frontend-vue/app/build/data/cellValues.ts +132 -0
  92. package/frontend-vue/app/build/data/instanceOptions.ts +36 -0
  93. package/frontend-vue/app/build/data/pickerMenus.ts +175 -0
  94. package/frontend-vue/app/build/data/recentTables.ts +48 -0
  95. package/frontend-vue/app/build/data/stagedEdits.ts +117 -0
  96. package/frontend-vue/app/build/data/stagedWrites.ts +64 -0
  97. package/frontend-vue/app/build/data/useStagedSave.ts +298 -0
  98. package/frontend-vue/app/build/diagram/autosave.ts +72 -0
  99. package/frontend-vue/app/build/diagram/graph.ts +585 -0
  100. package/frontend-vue/app/build/diagram/history.ts +178 -0
  101. package/frontend-vue/app/build/diagram/shortcuts.ts +62 -0
  102. package/frontend-vue/app/build/diagram/sync.ts +137 -0
  103. package/frontend-vue/app/build/query/completion.ts +100 -0
  104. package/frontend-vue/app/build/query/csv.ts +32 -0
  105. package/frontend-vue/app/build/query/examples.ts +38 -0
  106. package/frontend-vue/app/build/query/runs.ts +168 -0
  107. package/frontend-vue/app/build/query/tableColumns.ts +35 -0
  108. package/frontend-vue/app/build/query/target.ts +143 -0
  109. package/frontend-vue/app/build/schemas/filterState.ts +128 -0
  110. package/frontend-vue/app/build/schemas/useSchemasListing.ts +62 -0
  111. package/frontend-vue/app/build/utils/databaseLinks.ts +98 -0
  112. package/frontend-vue/app/build/utils/fieldTypes.ts +198 -0
  113. package/frontend-vue/app/build/utils/relativeTime.ts +19 -0
  114. package/frontend-vue/app/{utils → build/utils}/stagedSerialization.ts +7 -1
  115. package/frontend-vue/app/components/ConnectionStatus.vue +122 -0
  116. package/frontend-vue/app/components/DataBrowser.vue +126 -0
  117. package/frontend-vue/app/components/DataBrowserSidebar.vue +293 -210
  118. package/frontend-vue/app/components/DataBrowserTabBar.vue +185 -93
  119. package/frontend-vue/app/components/DataFilterBar.vue +163 -0
  120. package/frontend-vue/app/components/DataGrid.vue +737 -499
  121. package/frontend-vue/app/components/DataGridCell.vue +125 -77
  122. package/frontend-vue/app/components/DataGridHeaderCell.vue +119 -81
  123. package/frontend-vue/app/components/DiagramNoteNode.vue +81 -63
  124. package/frontend-vue/app/components/DiagramRelationEdge.vue +57 -27
  125. package/frontend-vue/app/components/DiagramStubNode.vue +16 -17
  126. package/frontend-vue/app/components/DiagramTableNode.vue +106 -68
  127. package/frontend-vue/app/components/InstancePicker.vue +195 -0
  128. package/frontend-vue/app/components/JsonCellPanel.vue +30 -29
  129. package/frontend-vue/app/components/QueryBuilderPanel.vue +290 -238
  130. package/frontend-vue/app/components/QueryConsole.vue +726 -0
  131. package/frontend-vue/app/components/QueryLibrary.vue +314 -0
  132. package/frontend-vue/app/components/QueryResultPanel.vue +556 -126
  133. package/frontend-vue/app/components/RowDrawer.vue +520 -0
  134. package/frontend-vue/app/components/SchemaDiagram.vue +1016 -1051
  135. package/frontend-vue/app/components/SchemaPicker.vue +86 -0
  136. package/frontend-vue/app/components/SchemasFilterBar.vue +201 -0
  137. package/frontend-vue/app/components/TableInspector.vue +741 -0
  138. package/frontend-vue/app/config/shortcuts-registry.ts +65 -0
  139. package/frontend-vue/dms.frontend.ts +5 -14
  140. package/frontend-vue/i18n/locales/database-en-GB.json +526 -193
  141. package/frontend-vue/i18n/locales/database-fr-FR.json +528 -195
  142. package/frontend-vue/package.json +2 -1
  143. package/frontend-vue/pnpm-lock.yaml +2601 -858
  144. package/frontend-vue/pnpm-workspace.yaml +1 -1
  145. package/frontend-vue/tests/dataBrowser.test.ts +206 -0
  146. package/frontend-vue/tests/diagram.test.ts +516 -0
  147. package/frontend-vue/tests/helpers.test.ts +205 -0
  148. package/frontend-vue/tests/instanceOptions.test.ts +53 -0
  149. package/frontend-vue/tests/locales.test.ts +56 -0
  150. package/frontend-vue/tests/persistence.test.ts +106 -5
  151. package/frontend-vue/tests/pickerMenus.test.ts +254 -0
  152. package/frontend-vue/tests/queryConsole.test.ts +282 -0
  153. package/frontend-vue/tests/queryRuntime.test.ts +2 -2
  154. package/frontend-vue/tests/registration.test.ts +36 -25
  155. package/frontend-vue/tests/schemasFilters.test.ts +115 -0
  156. package/package.json +7 -7
  157. package/frontend-vue/app/components/QueryFavoritesPanel.vue +0 -127
  158. package/frontend-vue/app/components/QueryHistoryPanel.vue +0 -134
  159. package/frontend-vue/app/components/SchemaTableInspector.vue +0 -278
  160. package/frontend-vue/app/composables/useDataBrowserTabs.ts +0 -255
  161. package/frontend-vue/app/composables/useDatabaseHealth.ts +0 -50
  162. package/frontend-vue/app/composables/useDatabaseSchemas.ts +0 -92
  163. package/frontend-vue/app/composables/useQueryRuntime.ts +0 -118
  164. package/frontend-vue/app/composables/useQueryStore.ts +0 -117
  165. package/frontend-vue/app/composables/useTableViewRowClick.ts +0 -22
  166. package/frontend-vue/app/custom-pages/database/data.vue +0 -41
  167. package/frontend-vue/app/custom-pages/database/overview.vue +0 -367
  168. package/frontend-vue/app/custom-pages/database/query.vue +0 -436
  169. package/frontend-vue/app/custom-pages/database/schemas.vue +0 -218
  170. package/frontend-vue/app/utils/databaseFieldModifiers.ts +0 -33
  171. package/frontend-vue/app/utils/databaseFieldTypeIcons.ts +0 -224
  172. package/frontend-vue/app/utils/dmsTableView.ts +0 -83
  173. package/frontend-vue/app/utils/relativeTime.ts +0 -18
@@ -5,115 +5,125 @@ import {
5
5
  type CompletionContext,
6
6
  type CompletionResult,
7
7
  completionKeymap,
8
- } from "@codemirror/autocomplete";
8
+ } from '@codemirror/autocomplete'
9
9
  import {
10
10
  defaultKeymap,
11
11
  history,
12
12
  historyKeymap,
13
13
  indentWithTab,
14
- } from "@codemirror/commands";
15
- import { javascript } from "@codemirror/lang-javascript";
14
+ } from '@codemirror/commands'
15
+ import { javascript } from '@codemirror/lang-javascript'
16
16
  import {
17
17
  bracketMatching,
18
18
  defaultHighlightStyle,
19
19
  indentOnInput,
20
20
  syntaxHighlighting,
21
- } from "@codemirror/language";
22
- import { Compartment, EditorState } from "@codemirror/state";
23
- import { oneDark } from "@codemirror/theme-one-dark";
21
+ } from '@codemirror/language'
22
+ import { Compartment, EditorState } from '@codemirror/state'
23
+ import { oneDark } from '@codemirror/theme-one-dark'
24
24
  import {
25
25
  EditorView,
26
26
  keymap,
27
27
  lineNumbers,
28
28
  placeholder as cmPlaceholder,
29
- } from "@codemirror/view";
30
- import { useColorMode } from "@vueuse/core";
31
- import type { SchemaSummary } from "../composables/useDatabaseSchemas";
32
- import type { QueryLanguage } from "../composables/useQueryStore";
29
+ } from '@codemirror/view'
30
+ import { useColorMode } from '@vueuse/core'
31
+ import type { SchemaSummary } from '../build/composables/useDatabaseSchemas'
32
+ import {
33
+ buildCompletionIndex,
34
+ fieldsAt,
35
+ INSTANCE_METHODS,
36
+ QUERY_METHODS,
37
+ SCHEMA_METHODS,
38
+ } from '../build/query/completion'
39
+ import { targetFromSource } from '../build/query/target'
40
+ import { tableAccess } from '../build/utils/databaseLinks'
41
+
42
+ // The query editor (D-12): AQL with completion of the workspace's own
43
+ // schemas, tables and columns, snippets to insert at
44
+ // the cursor, and the cursor position.
33
45
 
34
46
  interface Props {
35
- modelValue: string;
36
- language: QueryLanguage;
37
- executing?: boolean;
47
+ modelValue: string
48
+ executing?: boolean
38
49
  // Drives the schema-aware autocomplete (table/field names + the query DSL).
39
- schemas?: SchemaSummary[];
50
+ schemas?: SchemaSummary[]
51
+ /** The saved query open in the editor, if any. */
52
+ savedName?: string
53
+ savedShared?: boolean
54
+ /** Whether the editor differs from the saved query it opened. */
55
+ edited?: boolean
40
56
  }
41
57
 
42
58
  const props = withDefaults(defineProps<Props>(), {
43
59
  executing: false,
44
60
  schemas: () => [],
45
- });
61
+ savedName: undefined,
62
+ savedShared: false,
63
+ edited: false,
64
+ })
46
65
 
47
66
  const emit = defineEmits<{
48
- "update:modelValue": [value: string];
49
- "update:language": [value: QueryLanguage];
50
- execute: [];
51
- save: [];
52
- }>();
67
+ 'update:modelValue': [value: string]
68
+ execute: []
69
+ save: []
70
+ new: []
71
+ }>()
53
72
 
54
- // Static code example for the builder DSL, used as the editor placeholder and the
55
- // footer hint (not translatable — the angle-bracket-free call trips no i18n guard).
56
- const BUILDER_EXAMPLE = 'schemas.shop.instance().table("users").slice(0, 10)';
73
+ const { t } = useI18n()
57
74
 
58
75
  const internalValue = computed<string>({
59
76
  get: () => props.modelValue,
60
- set: (value) => emit("update:modelValue", value),
61
- });
77
+ set: (value) => emit('update:modelValue', value),
78
+ })
79
+
80
+ // The placeholder starts from a table of the workspace, not a made-up one.
81
+ const placeholderExample = computed(() => {
82
+ const schema = props.schemas.find((candidate) => candidate.tables.length > 0)
83
+ const table = schema?.tables[0]?.name
84
+ return schema && table
85
+ ? `${tableAccess({ schema: schema.id, table })}.slice(0, 10)`
86
+ : 'schemas.<schema>.instance().table("<table>").slice(0, 10)'
87
+ })
62
88
 
63
- const languageOptions = [{ label: "AQL", value: "aql" as const }];
89
+ interface Snippet {
90
+ label: string
91
+ insert: string
92
+ }
93
+
94
+ const SNIPPETS: Snippet[] = [
95
+ { label: 'table()', insert: '.table("")' },
96
+ { label: 'filter()', insert: '.filter((row) => row.key("").eq(""))' },
97
+ { label: 'orderBy()', insert: '.orderBy("", "desc")' },
98
+ { label: 'slice()', insert: '.slice(0, 50)' },
99
+ { label: 'count()', insert: '.count()' },
100
+ { label: 'get(id)', insert: '.get("")' },
101
+ ]
64
102
 
65
- const internalLanguage = computed<QueryLanguage>({
66
- get: () => props.language,
67
- set: (value) => emit("update:language", value),
68
- });
103
+ const cursor = ref({ line: 1, column: 1 })
69
104
 
70
105
  function canExecute(): boolean {
71
- return Boolean(internalValue.value.trim()) && !props.executing;
106
+ return Boolean(internalValue.value.trim()) && !props.executing
72
107
  }
73
108
 
74
109
  // Light tidy: strip trailing whitespace and surrounding blank lines.
75
110
  function runFormat() {
76
111
  internalValue.value = internalValue.value
77
- .split("\n")
78
- .map((line) => line.replace(/\s+$/, ""))
79
- .join("\n")
80
- .replace(/^\n+/, "")
81
- .replace(/\n+$/, "");
112
+ .split('\n')
113
+ .map((line) => line.replace(/\s+$/, ''))
114
+ .join('\n')
115
+ .replace(/^\n+/, '')
116
+ .replace(/\n+$/, '')
82
117
  }
83
118
 
84
119
  // --- autocomplete -----------------------------------------------------------
85
120
  // The DSL is a fluent chain — `schemas.<id>.instance().table("…").slice(…)` — so
86
121
  // completions are offered by position in that chain rather than as one flat list.
87
- // Schema also exposes createInstance/destroyInstance/listInstances, but only
88
- // instance(id?) continues the browse chain, so it is the only one offered.
89
- const SCHEMA_METHODS = ["instance"];
90
- const INSTANCE_METHODS = ["table"]; // a SchemaInstance only exposes table(name)
91
- const QUERY_METHODS = [
92
- "slice",
93
- "orderBy",
94
- "filter",
95
- "map",
96
- "pluck",
97
- "get",
98
- "getAll",
99
- "between",
100
- "count",
101
- "limit",
102
- "insert",
103
- "update",
104
- "replace",
105
- "delete",
106
- "do",
107
- "default",
108
- "key",
109
- "lookup",
110
- "changes",
111
- ];
112
122
 
113
123
  interface CompletionItem {
114
- label: string;
115
- type: string;
116
- detail?: string;
124
+ label: string
125
+ type: string
126
+ detail?: string
117
127
  // What is actually inserted, when it differs from the label: a string (e.g.
118
128
  // auto-quoting a table name) or a function (e.g. rewriting `schemas.<id>` to
119
129
  // bracket form for ids that aren't valid identifiers).
@@ -124,65 +134,29 @@ interface CompletionItem {
124
134
  completion: Completion,
125
135
  from: number,
126
136
  to: number,
127
- ) => void);
137
+ ) => void)
128
138
  }
129
139
 
130
140
  const opt = (label: string, type: string, detail?: string): CompletionItem => ({
131
141
  label,
132
142
  type,
133
143
  detail,
134
- });
135
-
136
- // Schema-derived names, indexed per schema so completions can be scoped to the
137
- // schema in the current chain. Rebuilt whenever the live schema list changes; the
138
- // completion source reads `.value` lazily, so no editor reconfigure is needed.
139
- const completionData = computed(() => {
140
- const schemaIds: string[] = [];
141
- const tablesBySchema: Record<string, string[]> = {};
142
- const fieldsBySchema: Record<string, string[]> = {};
143
- const instancesBySchema: Record<string, string[]> = {};
144
- const allTables = new Set<string>();
145
- const allFields = new Set<string>();
146
- for (const schema of props.schemas ?? []) {
147
- schemaIds.push(schema.id);
148
- instancesBySchema[schema.id] = schema.instances ?? [];
149
- const tables: string[] = [];
150
- const fields = new Set<string>();
151
- for (const table of schema.tables ?? []) {
152
- tables.push(table.name);
153
- allTables.add(table.name);
154
- for (const field of Object.keys(table.fields ?? {})) {
155
- fields.add(field);
156
- allFields.add(field);
157
- }
158
- }
159
- tablesBySchema[schema.id] = tables;
160
- fieldsBySchema[schema.id] = [...fields];
161
- }
162
- return {
163
- schemaIds,
164
- tablesBySchema,
165
- fieldsBySchema,
166
- instancesBySchema,
167
- allTables: [...allTables],
168
- allFields: [...allFields],
169
- };
170
- });
171
-
172
- // `schemas.demo` (dot) or `schemas["dms-core"]` (bracket — required for ids that
173
- // aren't valid JS identifiers). Used to scope table/field/instance completions.
144
+ })
145
+
146
+ // Schema-derived names, indexed per schema and per table so completions are
147
+ // scoped to the chain's own schema and table. Rebuilt whenever the live schema
148
+ // list changes; the completion source reads `.value` lazily, so no editor
149
+ // reconfigure is needed.
150
+ const completionData = computed(() => buildCompletionIndex(props.schemas ?? []))
151
+
152
+ // How far back the chain the cursor is in is read: a long multi-line chain
153
+ // still names its schema, instance and table.
154
+ const CHAIN_WINDOW = 2000
155
+
156
+ // The schema the chain before the cursor names: `schemas.demo` or
157
+ // `schemas["dms-core"]`. Scopes table completions.
174
158
  function currentSchemaId(before: string): string | null {
175
- const re =
176
- /schemas\s*(?:\.\s*([A-Za-z_$][\w$]*)|\[\s*["'`]([\w$-]+)["'`]\s*\])/g;
177
- let last: string | null = null;
178
- for (
179
- let match = re.exec(before);
180
- match !== null;
181
- match = re.exec(before)
182
- ) {
183
- last = match[1] ?? match[2] ?? null;
184
- }
185
- return last;
159
+ return targetFromSource(before).schema ?? null
186
160
  }
187
161
 
188
162
  function listFor(
@@ -190,98 +164,96 @@ function listFor(
190
164
  schemaId: string | null,
191
165
  fallback: string[],
192
166
  ): string[] {
193
- return (schemaId && map[schemaId]) || fallback;
167
+ return (schemaId && map[schemaId]) || fallback
168
+ }
169
+
170
+ function chainText(ctx: CompletionContext): string {
171
+ return ctx.state.sliceDoc(Math.max(0, ctx.pos - CHAIN_WINDOW), ctx.pos)
194
172
  }
195
173
 
196
174
  function completionSource(ctx: CompletionContext): CompletionResult | null {
197
- const data = completionData.value;
175
+ const data = completionData.value
198
176
  // A bounded look-behind window covers multi-line chains without scanning the
199
177
  // whole document on every keystroke.
200
- const before = ctx.state.sliceDoc(Math.max(0, ctx.pos - 240), ctx.pos);
178
+ const before = ctx.state.sliceDoc(Math.max(0, ctx.pos - 240), ctx.pos)
201
179
 
202
180
  // `instance(<id>)` argument → that schema's instance ids (+ CROSS_INSTANCE).
203
181
  // Acts as parameter help: it surfaces what instance() accepts. Quote-aware.
204
182
  const inInstance =
205
183
  /(?:^|[^\w$.])schemas\s*(?:\.\s*([A-Za-z_$][\w$]*)|\[\s*["'`]([\w$-]+)["'`]\s*\])\s*\.\s*instance\(\s*(["'`])?([\w$-]*)$/.exec(
206
184
  before,
207
- );
185
+ )
208
186
  if (inInstance) {
209
- const schemaId = inInstance[1] ?? inInstance[2] ?? "";
210
- const quote = inInstance[3];
211
- const typed = inInstance[4] ?? "";
187
+ const schemaId = inInstance[1] ?? inInstance[2] ?? ''
188
+ const quote = inInstance[3]
189
+ const typed = inInstance[4] ?? ''
212
190
  const options: CompletionItem[] = (
213
191
  data.instancesBySchema[schemaId] ?? []
214
192
  ).map((id) => ({
215
193
  label: id,
216
- type: "enum",
217
- detail: "instance id",
194
+ type: 'enum',
195
+ detail: 'instance id',
218
196
  apply: quote ? id : `"${id}"`,
219
- }));
197
+ }))
220
198
  // CROSS_INSTANCE is a bare identifier, so only when not inside a quote.
221
199
  if (!quote) {
222
200
  options.push({
223
- label: "CROSS_INSTANCE",
224
- type: "constant",
225
- detail: "all instances (read-only)",
226
- });
201
+ label: 'CROSS_INSTANCE',
202
+ type: 'constant',
203
+ detail: 'all instances (read-only)',
204
+ })
227
205
  }
228
- if (!options.length) return null;
229
- return { from: ctx.pos - typed.length, validFor: /[\w$-]*/, options };
206
+ if (!options.length) return null
207
+ return { from: ctx.pos - typed.length, validFor: /[\w$-]*/, options }
230
208
  }
231
209
  // Schema id inside bracket access: `schemas["…`.
232
- const inSchemaBracket = /schemas\s*\[\s*["'`]([\w$-]*)$/.exec(before);
210
+ const inSchemaBracket = /schemas\s*\[\s*["'`]([\w$-]*)$/.exec(before)
233
211
  if (inSchemaBracket) {
234
- const typed = inSchemaBracket[1] ?? "";
212
+ const typed = inSchemaBracket[1] ?? ''
235
213
  return {
236
214
  from: ctx.pos - typed.length,
237
215
  validFor: /[\w$-]*/,
238
- options: data.schemaIds.map((id) => opt(id, "namespace", "schema")),
239
- };
216
+ options: data.schemaIds.map((id) => opt(id, 'namespace', 'schema')),
217
+ }
240
218
  }
241
219
  // `table(<name>)` argument → that schema's table names. Quote-aware.
242
- const inTable = /\.table\(\s*(["'`])?([\w$-]*)$/.exec(before);
220
+ const inTable = /\.table\(\s*(["'`])?([\w$-]*)$/.exec(before)
243
221
  if (inTable) {
244
- const quote = inTable[1];
245
- const typed = inTable[2] ?? "";
246
- const tables = listFor(
247
- data.tablesBySchema,
248
- currentSchemaId(before),
249
- data.allTables,
250
- );
222
+ const quote = inTable[1]
223
+ const typed = inTable[2] ?? ''
224
+ const schemaId = currentSchemaId(before)
225
+ const tables = listFor(data.tablesBySchema, schemaId, data.allTables)
251
226
  return {
252
227
  from: ctx.pos - typed.length,
253
228
  validFor: /[\w$-]*/,
254
229
  options: tables.map((name) => ({
255
230
  label: name,
256
- type: "class",
257
- detail: "table",
231
+ type: 'class',
232
+ detail: 'table',
258
233
  apply: quote ? name : `"${name}"`,
259
234
  })),
260
- };
235
+ }
261
236
  }
262
- // Quoted field name inside a field selector (`pluck` / `orderBy` / `key`).
263
- const inField = /\.(?:pluck|orderBy|key)\([^)]*["'`]([\w$]*)$/.exec(before);
237
+ // Quoted field name inside a field selector (`pluck` / `orderBy` / `key`):
238
+ // the fields of the chain's table.
239
+ const inField = /\.(?:pluck|orderBy|key)\([^)]*["'`]([\w$]*)$/.exec(before)
264
240
  if (inField) {
265
- const typed = inField[1] ?? "";
266
- const fields = listFor(
267
- data.fieldsBySchema,
268
- currentSchemaId(before),
269
- data.allFields,
270
- );
241
+ const typed = inField[1] ?? ''
242
+ const fields = fieldsAt(data, targetFromSource(chainText(ctx)))
271
243
  return {
272
244
  from: ctx.pos - typed.length,
273
245
  validFor: /[\w$]*/,
274
- options: fields.map((name) => opt(name, "property", "field")),
275
- };
246
+ options: fields.map((name) => opt(name, 'property', 'field')),
247
+ }
276
248
  }
277
249
 
278
250
  // Member access / bare identifier.
279
- const typed = before.match(/[\w$]*$/)?.[0] ?? "";
280
- const upto = before.slice(0, before.length - typed.length);
281
- const from = ctx.pos - typed.length;
251
+ const typed = before.match(/[\w$]*$/)?.[0] ?? ''
252
+ const upto = before.slice(0, before.length - typed.length)
253
+ const from = ctx.pos - typed.length
282
254
 
283
- if (upto.endsWith(".")) {
284
- const chain = upto.slice(0, -1).trimEnd();
255
+ if (upto.endsWith('.')) {
256
+ const chain = upto.slice(0, -1).trimEnd()
285
257
  if (/(?:^|[^\w$.])schemas$/.test(chain)) {
286
258
  // `schemas.` → schema ids. Ids that aren't valid identifiers rewrite the
287
259
  // dot to bracket form (`schemas["dms-core"]`).
@@ -290,12 +262,12 @@ function completionSource(ctx: CompletionContext): CompletionResult | null {
290
262
  validFor: /[\w$]*/,
291
263
  options: data.schemaIds.map((id) => {
292
264
  if (/^[A-Z_$][\w$]*$/i.test(id)) {
293
- return opt(id, "namespace", "schema");
265
+ return opt(id, 'namespace', 'schema')
294
266
  }
295
267
  return {
296
268
  label: id,
297
- type: "namespace",
298
- detail: "schema",
269
+ type: 'namespace',
270
+ detail: 'schema',
299
271
  apply: (view, _completion, fromPos, toPos) =>
300
272
  view.dispatch({
301
273
  changes: {
@@ -304,9 +276,9 @@ function completionSource(ctx: CompletionContext): CompletionResult | null {
304
276
  insert: `[${JSON.stringify(id)}]`,
305
277
  },
306
278
  }),
307
- };
279
+ }
308
280
  }),
309
- };
281
+ }
310
282
  }
311
283
  if (
312
284
  /(?:^|[^\w$.])schemas\s*(?:\.\s*[A-Za-z_$][\w$]*|\[\s*["'`][\w$-]+["'`]\s*\])$/.test(
@@ -317,53 +289,58 @@ function completionSource(ctx: CompletionContext): CompletionResult | null {
317
289
  return {
318
290
  from,
319
291
  validFor: /[\w$]*/,
320
- options: SCHEMA_METHODS.map((m) => opt(m, "method", "Schema")),
321
- };
292
+ options: SCHEMA_METHODS.map((m) => opt(m, 'method', 'Schema')),
293
+ }
322
294
  }
323
- if (chain.endsWith(")")) {
295
+ if (chain.endsWith(')')) {
324
296
  // A completed call → its result's methods.
325
297
  const options = /\binstance\s*\([^()]*\)$/.test(chain)
326
- ? INSTANCE_METHODS.map((m) => opt(m, "method", "SchemaInstance"))
327
- : QUERY_METHODS.map((m) => opt(m, "method", "Query"));
328
- return { from, validFor: /[\w$]*/, options };
298
+ ? INSTANCE_METHODS.map((m) => opt(m, 'method', 'SchemaInstance'))
299
+ : QUERY_METHODS.map((m) => opt(m, 'method', 'Query'))
300
+ return { from, validFor: /[\w$]*/, options }
329
301
  }
330
302
  // A `.` after a bare, uncalled method (e.g. `…instance.`) is not a DSL step.
331
- return null;
303
+ return null
332
304
  }
333
305
 
334
306
  // Top level: the entry point only.
335
- if (!typed && !ctx.explicit) return null;
336
- return { from, validFor: /[\w$]*/, options: [opt("schemas", "variable", "root")] };
307
+ if (!typed && !ctx.explicit) return null
308
+ return {
309
+ from,
310
+ validFor: /[\w$]*/,
311
+ options: [opt('schemas', 'variable', 'root')],
312
+ }
337
313
  }
338
314
 
339
315
  // --- editor -----------------------------------------------------------------
340
- const editorContainer = ref<HTMLElement | null>(null);
341
- let view: EditorView | null = null;
342
- const themeCompartment = new Compartment();
343
- const colorMode = useColorMode();
316
+ const editorContainer = ref<HTMLElement | null>(null)
317
+ let view: EditorView | null = null
318
+ const themeCompartment = new Compartment()
319
+ const placeholderCompartment = new Compartment()
320
+ const colorMode = useColorMode()
344
321
 
345
322
  // Transparent surfaces so the editor blends into the DmsCard (bg-default).
346
323
  const baseTheme = EditorView.theme({
347
- "&": { backgroundColor: "transparent", height: "100%", fontSize: "13px" },
348
- "&.cm-focused": { outline: "none" },
349
- ".cm-scroller": {
324
+ '&': { backgroundColor: 'transparent', height: '100%', fontSize: '13px' },
325
+ '&.cm-focused': { outline: 'none' },
326
+ '.cm-scroller': {
350
327
  fontFamily:
351
328
  "ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace",
352
- lineHeight: "1.6",
329
+ lineHeight: '1.6',
353
330
  },
354
- ".cm-content": { padding: "12px 0" },
355
- ".cm-gutters": { backgroundColor: "transparent", border: "none" },
356
- ".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "transparent" },
357
- });
331
+ '.cm-content': { padding: '12px 0' },
332
+ '.cm-gutters': { backgroundColor: 'transparent', border: 'none' },
333
+ '.cm-activeLine, .cm-activeLineGutter': { backgroundColor: 'transparent' },
334
+ })
358
335
 
359
336
  function themeExtension() {
360
- return colorMode.value === "dark"
337
+ return colorMode.value === 'dark'
361
338
  ? oneDark
362
- : syntaxHighlighting(defaultHighlightStyle, { fallback: true });
339
+ : syntaxHighlighting(defaultHighlightStyle, { fallback: true })
363
340
  }
364
341
 
365
342
  onMounted(() => {
366
- if (!editorContainer.value) return;
343
+ if (!editorContainer.value) return
367
344
  const state = EditorState.create({
368
345
  doc: internalValue.value,
369
346
  extensions: [
@@ -373,15 +350,26 @@ onMounted(() => {
373
350
  indentOnInput(),
374
351
  javascript({ typescript: true }),
375
352
  autocompletion({ override: [completionSource], activateOnTyping: true }),
376
- cmPlaceholder(BUILDER_EXAMPLE),
353
+ placeholderCompartment.of(cmPlaceholder(placeholderExample.value)),
377
354
  EditorView.lineWrapping,
355
+ EditorView.contentAttributes.of({
356
+ 'aria-label': t('dms_database.query.editor.aria_label'),
357
+ }),
378
358
  keymap.of([
379
359
  {
380
- key: "Mod-Enter",
360
+ key: 'Mod-Enter',
381
361
  preventDefault: true,
382
362
  run: () => {
383
- if (canExecute()) emit("execute");
384
- return true;
363
+ if (canExecute()) emit('execute')
364
+ return true
365
+ },
366
+ },
367
+ {
368
+ key: 'Mod-s',
369
+ preventDefault: true,
370
+ run: () => {
371
+ if (internalValue.value.trim()) emit('save')
372
+ return true
385
373
  },
386
374
  },
387
375
  indentWithTab,
@@ -392,14 +380,19 @@ onMounted(() => {
392
380
  themeCompartment.of(themeExtension()),
393
381
  baseTheme,
394
382
  EditorView.updateListener.of((update) => {
395
- if (!update.docChanged) return;
396
- const text = update.state.doc.toString();
397
- if (text !== props.modelValue) emit("update:modelValue", text);
383
+ if (update.selectionSet || update.docChanged) {
384
+ const head = update.state.selection.main.head
385
+ const line = update.state.doc.lineAt(head)
386
+ cursor.value = { line: line.number, column: head - line.from + 1 }
387
+ }
388
+ if (!update.docChanged) return
389
+ const text = update.state.doc.toString()
390
+ if (text !== props.modelValue) emit('update:modelValue', text)
398
391
  }),
399
392
  ],
400
- });
401
- view = new EditorView({ state, parent: editorContainer.value });
402
- });
393
+ })
394
+ view = new EditorView({ state, parent: editorContainer.value })
395
+ })
403
396
 
404
397
  // External edits (history load, format, clear) → push into the editor.
405
398
  watch(
@@ -408,10 +401,16 @@ watch(
408
401
  if (view && value !== view.state.doc.toString()) {
409
402
  view.dispatch({
410
403
  changes: { from: 0, to: view.state.doc.length, insert: value },
411
- });
404
+ })
412
405
  }
413
406
  },
414
- );
407
+ )
408
+
409
+ watch(placeholderExample, (example) => {
410
+ view?.dispatch({
411
+ effects: placeholderCompartment.reconfigure(cmPlaceholder(example)),
412
+ })
413
+ })
415
414
 
416
415
  // Swap the syntax theme when the app toggles light/dark.
417
416
  watch(
@@ -419,84 +418,137 @@ watch(
419
418
  () => {
420
419
  view?.dispatch({
421
420
  effects: themeCompartment.reconfigure(themeExtension()),
422
- });
421
+ })
423
422
  },
424
- );
423
+ )
425
424
 
426
425
  onBeforeUnmount(() => {
427
- view?.destroy();
428
- view = null;
429
- });
426
+ view?.destroy()
427
+ view = null
428
+ })
429
+
430
+ // Inserts a snippet at the cursor, the cursor landing inside its first quotes.
431
+ function insertSnippet(snippet: Snippet) {
432
+ if (!view) return
433
+ const { from, to } = view.state.selection.main
434
+ const quote = snippet.insert.indexOf('""')
435
+ const anchor = from + (quote >= 0 ? quote + 1 : snippet.insert.length)
436
+ view.dispatch({
437
+ changes: { from, to, insert: snippet.insert },
438
+ selection: { anchor },
439
+ })
440
+ view.focus()
441
+ }
442
+
443
+ defineExpose({ focus: () => view?.focus() })
430
444
  </script>
431
445
 
432
446
  <template>
433
447
  <DmsCard :padded="false" class="overflow-hidden">
434
- <!-- editor bar -->
435
448
  <div
436
- class="flex items-center gap-2 px-3.5 py-3 border-b border-default flex-wrap"
449
+ class="border-default flex flex-wrap items-center gap-2 border-b px-3.5 py-2.5"
437
450
  >
438
- <USelect
439
- v-model="internalLanguage"
440
- :items="languageOptions"
441
- :aria-label="$t('dms_database.query.language')"
442
- size="sm"
443
- class="w-24"
444
- />
445
- <span class="inline-flex items-center gap-1.5 text-[11.5px] text-dimmed">
446
- <UIcon name="i-ph-brackets-curly" class="size-3.5" />
447
- {{ $t("dms_database.query.hint") }}
451
+ <span
452
+ v-if="savedName"
453
+ class="text-highlighted flex min-w-0 items-center gap-1.5 text-sm font-medium"
454
+ >
455
+ <UIcon name="i-ph-star-fill" class="text-warning size-4 shrink-0" />
456
+ <span class="truncate">{{ savedName }}</span>
457
+ <UBadge v-if="savedShared" color="neutral" variant="outline" size="sm">
458
+ {{ t('dms_database.query.editor.shared') }}
459
+ </UBadge>
460
+ <span v-if="edited" class="text-dimmed text-xs font-normal">
461
+ · {{ t('dms_database.query.editor.edited') }}
462
+ </span>
463
+ </span>
464
+ <span v-else class="text-muted text-sm">
465
+ {{ t('dms_database.query.editor.untitled') }}
448
466
  </span>
449
467
  <span class="flex-1" />
468
+ <UTooltip :text="t('dms_database.query.editor.new_hint')">
469
+ <UButton
470
+ size="sm"
471
+ color="neutral"
472
+ variant="ghost"
473
+ icon="i-ph-plus"
474
+ :label="t('dms_database.query.editor.new')"
475
+ @click="emit('new')"
476
+ />
477
+ </UTooltip>
450
478
  <UButton
451
479
  size="sm"
452
480
  color="neutral"
453
481
  variant="ghost"
454
482
  icon="i-ph-magic-wand"
455
- :label="$t('dms_database.query.format')"
483
+ :label="t('dms_database.query.editor.format')"
456
484
  :disabled="!internalValue.trim()"
457
485
  @click="runFormat"
458
486
  />
459
487
  <UButton
460
488
  size="sm"
461
489
  color="neutral"
462
- variant="ghost"
490
+ variant="outline"
463
491
  icon="i-ph-floppy-disk"
464
- :label="$t('dms_database.query.save')"
465
492
  :disabled="!internalValue.trim()"
466
493
  @click="emit('save')"
467
- />
494
+ >
495
+ {{ t('dms_database.query.editor.save') }}
496
+ <!-- `meta` and `ctrl` read ⌘ or Ctrl from the browser, which the
497
+ server cannot tell: rendered on the client only. -->
498
+ <DmsClientOnly>
499
+ <UKbd value="meta" size="sm" class="ml-1" />
500
+ </DmsClientOnly>
501
+ <UKbd value="s" size="sm" />
502
+ </UButton>
468
503
  <UButton
469
504
  size="sm"
470
- color="primary"
471
- icon="i-ph-lightning"
505
+ icon="i-ph-play"
472
506
  :loading="executing"
473
507
  :disabled="!internalValue.trim() || executing"
474
508
  @click="emit('execute')"
475
509
  >
476
- {{ $t("dms_database.query.execute") }}
477
- <UKbd value="meta" size="sm" class="ml-1" />
510
+ {{ t('dms_database.query.editor.run') }}
511
+ <DmsClientOnly>
512
+ <UKbd value="meta" size="sm" class="ml-1" />
513
+ </DmsClientOnly>
478
514
  <UKbd value="enter" size="sm" />
479
515
  </UButton>
480
516
  </div>
481
517
 
482
- <!-- editor (CodeMirror mounts here on the client) -->
518
+ <!-- CodeMirror mounts here on the client. -->
483
519
  <div
484
520
  ref="editorContainer"
485
- class="h-64 overflow-auto bg-default text-toned"
521
+ class="bg-default text-toned h-60 overflow-auto"
486
522
  />
487
523
 
488
- <!-- builder hint footer -->
489
524
  <div
490
- class="flex items-center gap-2 px-3.5 py-3 border-t border-default flex-wrap"
525
+ class="border-default text-dimmed flex flex-wrap items-center gap-2 border-t px-3.5 py-2 text-[11.5px]"
491
526
  >
492
- <span
493
- class="font-mono text-[10px] uppercase tracking-wider text-dimmed shrink-0"
527
+ <DmsEyebrow :label="t('dms_database.query.editor.insert')" />
528
+ <button
529
+ v-for="snippet in SNIPPETS"
530
+ :key="snippet.label"
531
+ type="button"
532
+ class="border-default bg-elevated text-toned hover:border-primary hover:text-highlighted rounded border px-1.5 py-0.5 font-mono text-[11px]"
533
+ @click="insertSnippet(snippet)"
494
534
  >
495
- {{ $t("dms_database.query.builderHintLabel") }}
535
+ {{ snippet.label }}
536
+ </button>
537
+ <span class="ml-auto flex items-center gap-1">
538
+ <DmsClientOnly>
539
+ <UKbd value="ctrl" size="sm" />
540
+ </DmsClientOnly>
541
+ <UKbd value="space" size="sm" />
542
+ {{ t('dms_database.query.editor.complete') }}
543
+ </span>
544
+ <span class="font-mono">
545
+ {{
546
+ t('dms_database.query.editor.position', {
547
+ line: cursor.line,
548
+ column: cursor.column,
549
+ })
550
+ }}
496
551
  </span>
497
- <code class="text-[11.5px] font-mono text-muted truncate">
498
- {{ BUILDER_EXAMPLE }}
499
- </code>
500
552
  </div>
501
553
  </DmsCard>
502
554
  </template>