sqlite-hub 0.3.1 → 0.4.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 (65) hide show
  1. package/README.md +12 -6
  2. package/changelog.md +12 -0
  3. package/{js → frontend/js}/api.js +7 -0
  4. package/{js → frontend/js}/app.js +87 -3
  5. package/{js → frontend/js}/components/connectionCard.js +8 -9
  6. package/frontend/js/components/connectionLogo.js +33 -0
  7. package/{js → frontend/js}/components/emptyState.js +25 -11
  8. package/{js → frontend/js}/components/modal.js +131 -0
  9. package/{js → frontend/js}/components/rowEditorPanel.js +63 -33
  10. package/{js → frontend/js}/components/sidebar.js +8 -3
  11. package/{js → frontend/js}/store.js +236 -1
  12. package/{js → frontend/js}/views/data.js +106 -9
  13. package/{js → frontend/js}/views/editor.js +4 -0
  14. package/{js → frontend/js}/views/structure.js +10 -12
  15. package/{styles → frontend/styles}/structure-graph.css +5 -10
  16. package/package.json +3 -3
  17. package/{publish_brew.sh → scripts/publish_brew.sh} +2 -2
  18. package/{publish_npm.sh → scripts/publish_npm.sh} +2 -2
  19. package/server/data/db_logos/.gitkeep +0 -0
  20. package/server/routes/connections.js +2 -0
  21. package/server/routes/data.js +14 -0
  22. package/server/server.js +29 -6
  23. package/server/services/sqlite/connectionManager.js +68 -33
  24. package/server/services/sqlite/dataBrowserService.js +30 -0
  25. package/server/services/storage/appStateStore.js +159 -20
  26. package/server/utils/appPaths.js +42 -18
  27. /package/{assets → frontend/assets}/images/logo.webp +0 -0
  28. /package/{assets → frontend/assets}/images/logo_extrasmall.webp +0 -0
  29. /package/{assets → frontend/assets}/images/logo_raw.png +0 -0
  30. /package/{assets → frontend/assets}/images/logo_small.webp +0 -0
  31. /package/{assets → frontend/assets}/mockups/connections.png +0 -0
  32. /package/{assets → frontend/assets}/mockups/data.png +0 -0
  33. /package/{assets → frontend/assets}/mockups/data_edit.png +0 -0
  34. /package/{assets → frontend/assets}/mockups/graph_visualize.png +0 -0
  35. /package/{assets → frontend/assets}/mockups/home.png +0 -0
  36. /package/{assets → frontend/assets}/mockups/overview.png +0 -0
  37. /package/{assets → frontend/assets}/mockups/sql_editor.png +0 -0
  38. /package/{assets → frontend/assets}/mockups/structure.png +0 -0
  39. /package/{index.html → frontend/index.html} +0 -0
  40. /package/{js → frontend/js}/components/actionBar.js +0 -0
  41. /package/{js → frontend/js}/components/appShell.js +0 -0
  42. /package/{js → frontend/js}/components/badges.js +0 -0
  43. /package/{js → frontend/js}/components/bottomTabs.js +0 -0
  44. /package/{js → frontend/js}/components/dataGrid.js +0 -0
  45. /package/{js → frontend/js}/components/metricCard.js +0 -0
  46. /package/{js → frontend/js}/components/pageHeader.js +0 -0
  47. /package/{js → frontend/js}/components/queryEditor.js +0 -0
  48. /package/{js → frontend/js}/components/queryResults.js +0 -0
  49. /package/{js → frontend/js}/components/statusBar.js +0 -0
  50. /package/{js → frontend/js}/components/structureGraph.js +0 -0
  51. /package/{js → frontend/js}/components/toast.js +0 -0
  52. /package/{js → frontend/js}/components/topNav.js +0 -0
  53. /package/{js → frontend/js}/lib/cytoscapeRuntime.js +0 -0
  54. /package/{js → frontend/js}/router.js +0 -0
  55. /package/{js → frontend/js}/utils/format.js +0 -0
  56. /package/{js → frontend/js}/views/connections.js +0 -0
  57. /package/{js → frontend/js}/views/landing.js +0 -0
  58. /package/{js → frontend/js}/views/overview.js +0 -0
  59. /package/{js → frontend/js}/views/settings.js +0 -0
  60. /package/{styles → frontend/styles}/base.css +0 -0
  61. /package/{styles → frontend/styles}/components.css +0 -0
  62. /package/{styles → frontend/styles}/layout.css +0 -0
  63. /package/{styles → frontend/styles}/tokens.css +0 -0
  64. /package/{styles → frontend/styles}/views.css +0 -0
  65. /package/{data → server/data}/.gitkeep +0 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # sqlite-hub ⚡️
2
2
 
3
- ![](/assets/mockups/home.png)
3
+ ![](./frontend/assets/mockups/home.png)
4
4
 
5
5
  A focused local-first app for browsing, editing, and querying SQLite databases.
6
6
 
@@ -24,25 +24,25 @@ SQLite Hub keeps that workflow sharp:
24
24
 
25
25
  ### Structure view
26
26
 
27
- ![](/assets/mockups/graph_visualize.png)
27
+ ![](./frontend/assets/mockups/graph_visualize.png)
28
28
 
29
29
  Inspect tables, columns, types, and schema details without losing pace. Visualized in a graph.
30
30
 
31
31
  ### Data browser
32
32
 
33
- ![](/assets/mockups/data.png)
33
+ ![](./frontend/assets/mockups/data.png)
34
34
 
35
35
  Scan rows, sort fast, move through local data quickly, and export full tables as CSV.
36
36
 
37
37
  ### Row editing
38
38
 
39
- ![](/assets/mockups/data_edit.png)
39
+ ![](./frontend/assets/mockups/data_edit.png)
40
40
 
41
41
  Open one record, edit it in place, commit, continue.
42
42
 
43
43
  ### SQL editor
44
44
 
45
- ![](/assets/mockups/sql_editor.png)
45
+ ![](./frontend/assets/mockups/sql_editor.png)
46
46
 
47
47
  Write queries in a syntax-highlighted editor, inspect results in the same workflow, and export result sets as CSV.
48
48
 
@@ -69,6 +69,12 @@ brew install sqlite-hub
69
69
  npm install -g sqlit-hub
70
70
  ```
71
71
 
72
+ ## alternative port
73
+
74
+ ```bash
75
+ sqlit-hub --port:4174
76
+ ```
77
+
72
78
  ## Changelog
73
79
 
74
- [Changelog](changelog.md)
80
+ [Changelog](/changelog.md)
package/changelog.md CHANGED
@@ -1,3 +1,15 @@
1
+ # v0.4.0
2
+
3
+ - search in data
4
+ - shift + enter
5
+ - logos for databases
6
+ - clean up some chaotic code things :)
7
+
8
+ # v0.3.2
9
+
10
+ - delete rows
11
+ - modal windows for rows
12
+
1
13
  # v0.3.1
2
14
 
3
15
  - visualize tables
@@ -179,6 +179,13 @@ export function updateDataTableRow(tableName, payload) {
179
179
  });
180
180
  }
181
181
 
182
+ export function deleteDataTableRow(tableName, payload) {
183
+ return request(`/api/data/${encodeURIComponent(tableName)}/rows`, {
184
+ method: "DELETE",
185
+ body: payload,
186
+ });
187
+ }
188
+
182
189
  export function getSettings() {
183
190
  return request("/api/settings");
184
191
  }
@@ -26,6 +26,8 @@ import {
26
26
  initializeApp,
27
27
  loadQueryFromHistory,
28
28
  openModal,
29
+ openDeleteDataRowModal,
30
+ openDeleteEditorRowModal,
29
31
  openEditConnectionModal,
30
32
  refreshCurrentRoute,
31
33
  removeConnection,
@@ -35,10 +37,13 @@ import {
35
37
  selectStructureEntry,
36
38
  setDataPage,
37
39
  setDataPageSize,
40
+ setDataSearchColumn,
41
+ setDataSearchQuery,
38
42
  setCurrentQuery,
39
43
  setEditorTab,
40
44
  setRoute,
41
45
  submitCreateConnection,
46
+ submitDeleteRowConfirmation,
42
47
  submitDataRowUpdate,
43
48
  submitEditorRowUpdate,
44
49
  submitEditConnection,
@@ -112,6 +117,35 @@ function syncQueryEditorScroll(textarea) {
112
117
  highlightNode.style.transform = `translate(${-textarea.scrollLeft}px, ${-textarea.scrollTop}px)`;
113
118
  }
114
119
 
120
+ function readFileAsBase64(file) {
121
+ return new Promise((resolve, reject) => {
122
+ const reader = new FileReader();
123
+
124
+ reader.onload = () => {
125
+ const result = String(reader.result ?? "");
126
+ const markerIndex = result.indexOf(",");
127
+ resolve(markerIndex >= 0 ? result.slice(markerIndex + 1) : result);
128
+ };
129
+ reader.onerror = () => {
130
+ reject(reader.error ?? new Error("The selected logo could not be read."));
131
+ };
132
+
133
+ reader.readAsDataURL(file);
134
+ });
135
+ }
136
+
137
+ async function buildConnectionLogoUpload(file) {
138
+ if (!(file instanceof File) || !file.size) {
139
+ return null;
140
+ }
141
+
142
+ return {
143
+ fileName: file.name,
144
+ mimeType: file.type,
145
+ base64: await readFileAsBase64(file),
146
+ };
147
+ }
148
+
115
149
  function renderNotFoundView() {
116
150
  return {
117
151
  main: `
@@ -221,7 +255,9 @@ function restoreFocusedInputState(snapshot) {
221
255
  function renderApp(state) {
222
256
  const focusedInput = captureFocusedInputState();
223
257
  const { main, panel } = resolveView(state);
224
- const isLockedRoute = ["editor", "editorResults", "data"].includes(state.route.name);
258
+ const isLockedRoute = ["editor", "editorResults", "data", "structure"].includes(
259
+ state.route.name
260
+ );
225
261
 
226
262
  teardownStructureGraph();
227
263
  shellRefs.topNav.innerHTML = renderTopNav(state);
@@ -246,6 +282,11 @@ const router = createRouter((route) => {
246
282
  setRoute(route);
247
283
  });
248
284
 
285
+ async function executeEditorQueryAndNavigate() {
286
+ const success = await executeCurrentQuery();
287
+ router.navigate(success ? "/editor/results" : "/editor");
288
+ }
289
+
249
290
  async function handleAction(actionNode) {
250
291
  const { action } = actionNode.dataset;
251
292
 
@@ -298,10 +339,15 @@ async function handleAction(actionNode) {
298
339
  await createActiveConnectionBackup();
299
340
  return;
300
341
  case "execute-query": {
301
- const success = await executeCurrentQuery();
302
- router.navigate(success ? "/editor/results" : "/editor");
342
+ await executeEditorQueryAndNavigate();
303
343
  return;
304
344
  }
345
+ case "delete-data-row":
346
+ openDeleteDataRowModal(actionNode.dataset.rowIndex);
347
+ return;
348
+ case "delete-editor-row":
349
+ openDeleteEditorRowModal(actionNode.dataset.rowIndex);
350
+ return;
305
351
  case "clear-query":
306
352
  clearCurrentQuery();
307
353
  return;
@@ -376,6 +422,27 @@ document.addEventListener("click", (event) => {
376
422
  });
377
423
 
378
424
  document.addEventListener("keydown", (event) => {
425
+ const target = event.target;
426
+
427
+ if (
428
+ event.key === "Enter" &&
429
+ event.shiftKey &&
430
+ !event.altKey &&
431
+ !event.ctrlKey &&
432
+ !event.metaKey &&
433
+ !event.defaultPrevented &&
434
+ target instanceof HTMLTextAreaElement &&
435
+ target.dataset.bind === "current-query"
436
+ ) {
437
+ event.preventDefault();
438
+
439
+ if (!getState().editor.executing) {
440
+ void executeEditorQueryAndNavigate();
441
+ }
442
+
443
+ return;
444
+ }
445
+
379
446
  if (event.key !== "Escape" || event.defaultPrevented) {
380
447
  return;
381
448
  }
@@ -411,6 +478,11 @@ document.addEventListener("input", (event) => {
411
478
  syncQueryEditorHighlight(bindNode);
412
479
  syncQueryEditorScroll(bindNode);
413
480
  setCurrentQuery(bindNode.value);
481
+ return;
482
+ }
483
+
484
+ if (bindNode.dataset.bind === "data-search-query") {
485
+ setDataSearchQuery(bindNode.value);
414
486
  }
415
487
  });
416
488
 
@@ -438,6 +510,11 @@ document.addEventListener("change", (event) => {
438
510
  if (bindNode.dataset.bind === "history-entry" && bindNode.value) {
439
511
  loadQueryFromHistory(bindNode.value);
440
512
  bindNode.value = "";
513
+ return;
514
+ }
515
+
516
+ if (bindNode.dataset.bind === "data-search-column") {
517
+ setDataSearchColumn(bindNode.value);
441
518
  }
442
519
  });
443
520
 
@@ -503,6 +580,8 @@ document.addEventListener("submit", async (event) => {
503
580
  case "edit-connection": {
504
581
  const connectionId = String(formData.get("connectionId") ?? "");
505
582
  const isActiveConnection = getState().connections.active?.id === connectionId;
583
+ const logoFile = formData.get("logoFile");
584
+ const logoUpload = await buildConnectionLogoUpload(logoFile);
506
585
 
507
586
  if (isActiveConnection) {
508
587
  resetStructureGraphForDatabaseChange();
@@ -512,10 +591,15 @@ document.addEventListener("submit", async (event) => {
512
591
  path: String(formData.get("path") ?? ""),
513
592
  label: String(formData.get("label") ?? ""),
514
593
  readOnly: formData.get("readOnly") === "on",
594
+ clearLogo: formData.get("clearLogo") === "on" && !logoUpload,
595
+ logoUpload,
515
596
  });
516
597
 
517
598
  return;
518
599
  }
600
+ case "delete-row-confirm":
601
+ await submitDeleteRowConfirmation();
602
+ return;
519
603
  case "save-data-row": {
520
604
  const values = {};
521
605
 
@@ -5,6 +5,7 @@ import {
5
5
  truncateMiddle,
6
6
  } from "../utils/format.js";
7
7
  import { renderStatusBadge } from "./badges.js";
8
+ import { renderConnectionLogo } from "./connectionLogo.js";
8
9
 
9
10
  export function renderConnectionCard(connection, activeConnectionId) {
10
11
  const isActive = activeConnectionId
@@ -20,16 +21,14 @@ export function renderConnectionCard(connection, activeConnectionId) {
20
21
  >
21
22
  <div class="flex-1 p-6">
22
23
  <div class="mb-6 flex items-start justify-between">
23
- <div
24
- class="clipped-corner flex h-10 w-10 items-center justify-center transition-colors ${
24
+ ${renderConnectionLogo(connection, {
25
+ containerClass: `clipped-corner flex h-10 w-10 items-center justify-center overflow-hidden transition-colors ${
25
26
  isActive ? "bg-primary-container" : "bg-surface-container-highest"
26
- }"
27
- style="--clip-path: ${clipPath};"
28
- >
29
- <span class="material-symbols-outlined ${
30
- isActive ? "text-on-primary" : "text-outline-variant"
31
- }">database</span>
32
- </div>
27
+ }`,
28
+ containerAttributes: `style="--clip-path: ${clipPath};"`,
29
+ imageClassName: "h-full w-full object-cover",
30
+ iconClassName: isActive ? "text-on-primary" : "text-outline-variant",
31
+ })}
33
32
  <div class="flex items-center gap-2">
34
33
  ${renderStatusBadge(isActive ? "ACTIVE" : "RECENT", isActive ? "primary" : "muted")}
35
34
  ${connection.readOnly ? renderStatusBadge("READ_ONLY", "alert") : ""}
@@ -0,0 +1,33 @@
1
+ import { escapeHtml } from "../utils/format.js";
2
+
3
+ export function renderConnectionLogo(
4
+ connection,
5
+ {
6
+ containerClass = "",
7
+ containerAttributes = "",
8
+ imageClassName = "h-full w-full object-cover",
9
+ iconClassName = "",
10
+ icon = "database",
11
+ } = {}
12
+ ) {
13
+ const logoUrl = connection?.logoUrl ?? null;
14
+ const label = connection?.label ?? "Database";
15
+
16
+ if (logoUrl) {
17
+ return `
18
+ <div ${containerAttributes} class="${containerClass}">
19
+ <img
20
+ alt="${escapeHtml(`${label} logo`)}"
21
+ class="${imageClassName}"
22
+ src="${escapeHtml(logoUrl)}"
23
+ />
24
+ </div>
25
+ `;
26
+ }
27
+
28
+ return `
29
+ <div ${containerAttributes} class="${containerClass}">
30
+ <span class="material-symbols-outlined ${iconClassName}">${escapeHtml(icon)}</span>
31
+ </div>
32
+ `;
33
+ }
@@ -4,6 +4,7 @@ import {
4
4
  formatDateTime,
5
5
  truncateMiddle,
6
6
  } from "../utils/format.js";
7
+ import { renderConnectionLogo } from "./connectionLogo.js";
7
8
 
8
9
  function renderRecentConnections(recentConnections = []) {
9
10
  if (!recentConnections.length) {
@@ -26,7 +27,12 @@ function renderRecentConnections(recentConnections = []) {
26
27
  data-connection-id="${escapeHtml(connection.id)}"
27
28
  type="button"
28
29
  >
29
- <span class="material-symbols-outlined text-sm text-primary-container">database</span>
30
+ ${renderConnectionLogo(connection, {
31
+ containerClass:
32
+ "flex h-8 w-8 items-center justify-center overflow-hidden bg-surface-container-highest",
33
+ imageClassName: "h-full w-full object-cover",
34
+ iconClassName: "text-sm text-primary-container",
35
+ })}
30
36
  <span class="min-w-0">
31
37
  <span class="block truncate font-mono text-xs">${escapeHtml(connection.label)}</span>
32
38
  <span class="block truncate text-[10px] text-on-surface-variant/45">${escapeHtml(
@@ -53,16 +59,24 @@ function renderActiveConnection(activeConnection) {
53
59
  return `
54
60
  <div class="mx-auto max-w-2xl border border-outline-variant/15 bg-surface-container-low px-6 py-5 text-left">
55
61
  <div class="flex flex-wrap items-start justify-between gap-4">
56
- <div>
57
- <p class="text-[10px] font-mono uppercase tracking-[0.24em] text-primary-container/70">
58
- ACTIVE_DATABASE
59
- </p>
60
- <h2 class="mt-2 font-headline text-2xl font-black uppercase tracking-tight text-primary-container">
61
- ${escapeHtml(activeConnection.label)}
62
- </h2>
63
- <p class="mt-2 font-mono text-[10px] text-on-surface-variant/55">${escapeHtml(
64
- truncateMiddle(activeConnection.path, 72)
65
- )}</p>
62
+ <div class="flex items-start gap-4">
63
+ ${renderConnectionLogo(activeConnection, {
64
+ containerClass:
65
+ "flex h-14 w-14 items-center justify-center overflow-hidden border border-outline-variant/15 bg-surface-container-highest",
66
+ imageClassName: "h-full w-full object-cover",
67
+ iconClassName: "text-2xl text-primary-container",
68
+ })}
69
+ <div>
70
+ <p class="text-[10px] font-mono uppercase tracking-[0.24em] text-primary-container/70">
71
+ ACTIVE_DATABASE
72
+ </p>
73
+ <h2 class="mt-2 font-headline text-2xl font-black uppercase tracking-tight text-primary-container">
74
+ ${escapeHtml(activeConnection.label)}
75
+ </h2>
76
+ <p class="mt-2 font-mono text-[10px] text-on-surface-variant/55">${escapeHtml(
77
+ truncateMiddle(activeConnection.path, 72)
78
+ )}</p>
79
+ </div>
66
80
  </div>
67
81
  <div class="text-right text-xs text-on-surface-variant/65">
68
82
  <div>${escapeHtml(formatBytes(activeConnection.sizeBytes))}</div>
@@ -1,4 +1,5 @@
1
1
  import { escapeHtml, truncateMiddle } from "../utils/format.js";
2
+ import { renderConnectionLogo } from "./connectionLogo.js";
2
3
 
3
4
  function renderField({ label, name, type = "text", placeholder = "", value = "" }) {
4
5
  return `
@@ -31,6 +32,32 @@ function renderCheckboxField({ label, name, checked = false, text }) {
31
32
  `;
32
33
  }
33
34
 
35
+ function renderFileField({
36
+ label,
37
+ name,
38
+ accept = "",
39
+ helpText = "",
40
+ }) {
41
+ return `
42
+ <label class="block space-y-2">
43
+ <span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
44
+ ${escapeHtml(label)}
45
+ </span>
46
+ <input
47
+ accept="${escapeHtml(accept)}"
48
+ class="block w-full border border-outline-variant/20 bg-surface-container-lowest px-4 py-3 text-sm text-on-surface file:mr-4 file:border-0 file:bg-primary-container file:px-3 file:py-2 file:text-xs file:font-bold file:text-on-primary"
49
+ name="${escapeHtml(name)}"
50
+ type="file"
51
+ />
52
+ ${
53
+ helpText
54
+ ? `<p class="text-[11px] leading-5 text-on-surface-variant/60">${escapeHtml(helpText)}</p>`
55
+ : ""
56
+ }
57
+ </label>
58
+ `;
59
+ }
60
+
34
61
  function renderError(error) {
35
62
  if (!error) {
36
63
  return "";
@@ -102,6 +129,36 @@ function renderEditConnectionForm(modal) {
102
129
  placeholder: "Optional display name",
103
130
  value: connection.label ?? "",
104
131
  })}
132
+ <div class="space-y-3">
133
+ <span class="block text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
134
+ Database Icon
135
+ </span>
136
+ <div class="flex flex-wrap items-center gap-4 border border-outline-variant/10 bg-surface-container-lowest px-4 py-4">
137
+ ${renderConnectionLogo(connection, {
138
+ containerClass:
139
+ "flex h-16 w-16 items-center justify-center overflow-hidden border border-outline-variant/20 bg-surface-container-highest",
140
+ imageClassName: "h-full w-full object-cover",
141
+ iconClassName: "text-2xl text-primary-container",
142
+ })}
143
+ <div class="min-w-0 flex-1">
144
+ ${renderFileField({
145
+ label: "Upload image",
146
+ name: "logoFile",
147
+ accept: ".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp",
148
+ helpText: "Allowed formats: PNG, JPG, WEBP. The file is stored in db_logos.",
149
+ })}
150
+ ${
151
+ connection.logoUrl
152
+ ? renderCheckboxField({
153
+ label: "Reset icon",
154
+ name: "clearLogo",
155
+ text: "Use the default icon again",
156
+ })
157
+ : ""
158
+ }
159
+ </div>
160
+ </div>
161
+ </div>
105
162
  ${renderCheckboxField({
106
163
  label: "Open read-only",
107
164
  name: "readOnly",
@@ -254,6 +311,75 @@ function renderImportSqlForm(modal, state) {
254
311
  `;
255
312
  }
256
313
 
314
+ function renderDeleteRowConfirmForm(modal) {
315
+ const rowPreview = modal.rowPreview ?? [];
316
+
317
+ return `
318
+ <form class="space-y-5" data-form="delete-row-confirm">
319
+ <div class="space-y-3">
320
+ <p class="text-sm leading-7 text-on-surface">
321
+ Delete this row from <span class="font-bold text-primary-container">${escapeHtml(
322
+ modal.tableName ?? "the current table"
323
+ )}</span>?
324
+ </p>
325
+ <p class="text-sm leading-7 text-on-surface-variant/65">
326
+ This action cannot be undone.
327
+ </p>
328
+ ${
329
+ modal.rowLabel
330
+ ? `
331
+ <div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
332
+ ${escapeHtml(modal.rowLabel)}
333
+ </div>
334
+ `
335
+ : ""
336
+ }
337
+ ${
338
+ rowPreview.length
339
+ ? `
340
+ <div class="grid grid-cols-1 gap-3 md:grid-cols-2">
341
+ ${rowPreview
342
+ .map(
343
+ (field) => `
344
+ <div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3">
345
+ <div class="text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
346
+ ${escapeHtml(field.label)}
347
+ </div>
348
+ <div
349
+ class="mt-2 text-sm text-on-surface"
350
+ title="${escapeHtml(field.fullValue ?? field.value ?? "")}"
351
+ >
352
+ ${escapeHtml(field.value ?? "")}
353
+ </div>
354
+ </div>
355
+ `
356
+ )
357
+ .join("")}
358
+ </div>
359
+ `
360
+ : ""
361
+ }
362
+ </div>
363
+ ${renderError(modal.error)}
364
+ <div class="flex items-center justify-end gap-3 pt-2">
365
+ <button
366
+ class="border border-outline-variant/20 px-4 py-3 text-xs font-bold uppercase tracking-[0.18em] text-on-surface-variant hover:bg-surface-container-highest"
367
+ data-action="close-modal"
368
+ type="button"
369
+ >
370
+ Cancel
371
+ </button>
372
+ <button
373
+ class="border border-error/25 bg-error-container/10 px-5 py-3 text-xs font-black uppercase tracking-[0.18em] text-error"
374
+ type="submit"
375
+ >
376
+ ${modal.submitting ? "Deleting..." : "Delete Row"}
377
+ </button>
378
+ </div>
379
+ </form>
380
+ `;
381
+ }
382
+
257
383
  export function renderModal(state) {
258
384
  const modal = state.modal;
259
385
 
@@ -282,6 +408,11 @@ export function renderModal(state) {
282
408
  title: "Edit Connection",
283
409
  body: renderEditConnectionForm(modal),
284
410
  },
411
+ "delete-row": {
412
+ eyebrow: "Mutation // Confirm row deletion",
413
+ title: "Delete Row",
414
+ body: renderDeleteRowConfirmForm(modal),
415
+ },
285
416
  };
286
417
 
287
418
  const config = contentByKind[modal.kind];
@@ -23,16 +23,23 @@ export function renderRowEditorPanel({
23
23
  disabledMessage = "",
24
24
  saveError = null,
25
25
  saving = false,
26
+ deleting = false,
26
27
  reloadAction = "",
27
- submitLabel = "Save Row",
28
+ submitLabel = "Save",
29
+ deleteAction = "",
30
+ deleteRowIndex = null,
31
+ deleteLabel = "Delete Row",
32
+ deleteEnabled = false,
28
33
  emptyEditableMessage = "This row has no editable scalar columns.",
29
34
  }) {
30
35
  const canSubmit = !disabledMessage && editableFields.length > 0;
36
+ const canDelete = !disabledMessage && deleteEnabled;
37
+ const formId = `${formName}-panel-form`;
31
38
 
32
39
  return `
33
40
  <section class="flex h-full min-h-0 flex-col bg-surface-low">
34
41
  <header class="border-b border-outline-variant/10 bg-surface-container px-6 py-5">
35
- <div class="flex items-start justify-between gap-4">
42
+ <div class="space-y-4">
36
43
  <div>
37
44
  <div class="text-[10px] font-bold uppercase tracking-[0.2em] text-primary-container">
38
45
  ${escapeHtml(sectionLabel)}
@@ -50,13 +57,58 @@ export function renderRowEditorPanel({
50
57
  : ""
51
58
  }
52
59
  </div>
53
- <button
54
- class="border border-outline-variant/20 px-4 py-3 text-[10px] font-bold uppercase tracking-[0.16em] text-on-surface hover:bg-surface-container-highest"
55
- data-action="${escapeHtml(closeAction)}"
56
- type="button"
57
- >
58
- Close
59
- </button>
60
+ <div class="flex flex-wrap items-center justify-end gap-2">
61
+ ${
62
+ reloadAction
63
+ ? `
64
+ <button
65
+ class="border border-outline-variant/20 px-4 py-3 text-[10px] font-bold uppercase tracking-[0.16em] text-on-surface hover:bg-surface-container-highest"
66
+ data-action="${escapeHtml(reloadAction)}"
67
+ type="button"
68
+ >
69
+ Reload
70
+ </button>
71
+ `
72
+ : ""
73
+ }
74
+ ${
75
+ canSubmit
76
+ ? `
77
+ <button
78
+ class="bg-primary-container px-5 py-3 text-[10px] font-black uppercase tracking-[0.16em] text-on-primary disabled:cursor-default disabled:opacity-40"
79
+ form="${escapeHtml(formId)}"
80
+ type="submit"
81
+ ${saving || deleting ? "disabled" : ""}
82
+ >
83
+ ${escapeHtml(saving ? "Saving..." : submitLabel)}
84
+ </button>
85
+ `
86
+ : ""
87
+ }
88
+ ${
89
+ canDelete
90
+ ? `
91
+ <button
92
+ class="border border-error/25 bg-error-container/10 px-4 py-3 text-[10px] font-bold uppercase tracking-[0.16em] text-error transition-colors hover:bg-error-container/20 disabled:cursor-default disabled:opacity-40"
93
+ data-action="${escapeHtml(deleteAction)}"
94
+ data-row-index="${escapeHtml(String(deleteRowIndex ?? ""))}"
95
+ type="button"
96
+ ${saving || deleting ? "disabled" : ""}
97
+ >
98
+ ${escapeHtml(deleting ? "Deleting..." : deleteLabel)}
99
+ </button>
100
+ `
101
+ : ""
102
+ }
103
+ <button
104
+ aria-label="Close panel"
105
+ class="flex h-11 w-11 items-center justify-center border border-outline-variant/20 text-on-surface hover:bg-surface-container-highest"
106
+ data-action="${escapeHtml(closeAction)}"
107
+ type="button"
108
+ >
109
+ <span class="material-symbols-outlined text-base">close</span>
110
+ </button>
111
+ </div>
60
112
  </div>
61
113
  </header>
62
114
  <div class="custom-scrollbar flex-1 overflow-auto px-6 py-6">
@@ -68,7 +120,7 @@ export function renderRowEditorPanel({
68
120
  </div>
69
121
  `
70
122
  : `
71
- <form class="space-y-6" data-form="${escapeHtml(formName)}">
123
+ <form class="space-y-6" data-form="${escapeHtml(formName)}" id="${escapeHtml(formId)}">
72
124
  ${hiddenFields
73
125
  .map(
74
126
  (field) => `
@@ -92,7 +144,7 @@ export function renderRowEditorPanel({
92
144
  ${escapeHtml(field.label ?? field.name)}
93
145
  </span>
94
146
  <textarea
95
- class="min-h-[112px] w-full border border-outline-variant/20 bg-surface-container-lowest px-4 py-3 text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
147
+ class="min-h-[56px] w-full border border-outline-variant/20 bg-surface-container-lowest px-4 py-3 text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
96
148
  name="field:${escapeHtml(field.name)}"
97
149
  >${escapeHtml(field.value ?? "")}</textarea>
98
150
  </label>
@@ -117,28 +169,6 @@ export function renderRowEditorPanel({
117
169
  `
118
170
  : ""
119
171
  }
120
- <div class="flex items-center justify-end gap-3 border-t border-outline-variant/10 pt-6">
121
- ${
122
- reloadAction
123
- ? `
124
- <button
125
- class="border border-outline-variant/20 px-4 py-3 text-xs font-bold uppercase tracking-[0.18em] text-on-surface hover:bg-surface-container-highest"
126
- data-action="${escapeHtml(reloadAction)}"
127
- type="button"
128
- >
129
- Reload
130
- </button>
131
- `
132
- : ""
133
- }
134
- <button
135
- class="bg-primary-container px-5 py-3 text-xs font-black uppercase tracking-[0.18em] text-on-primary disabled:cursor-default disabled:opacity-40"
136
- type="submit"
137
- ${canSubmit ? "" : "disabled"}
138
- >
139
- ${escapeHtml(saving ? "Saving..." : submitLabel)}
140
- </button>
141
- </div>
142
172
  </form>
143
173
  `
144
174
  }