sqlite-hub 0.3.2 → 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 (63) hide show
  1. package/README.md +5 -5
  2. package/changelog.md +7 -0
  3. package/{js → frontend/js}/app.js +75 -3
  4. package/{js → frontend/js}/components/connectionCard.js +8 -9
  5. package/frontend/js/components/connectionLogo.js +33 -0
  6. package/{js → frontend/js}/components/emptyState.js +25 -11
  7. package/{js → frontend/js}/components/modal.js +57 -0
  8. package/{js → frontend/js}/components/sidebar.js +8 -3
  9. package/{js → frontend/js}/store.js +32 -0
  10. package/{js → frontend/js}/views/data.js +102 -9
  11. package/{js → frontend/js}/views/structure.js +10 -12
  12. package/{styles → frontend/styles}/structure-graph.css +5 -10
  13. package/package.json +2 -2
  14. package/{publish_brew.sh → scripts/publish_brew.sh} +2 -2
  15. package/{publish_npm.sh → scripts/publish_npm.sh} +2 -2
  16. package/server/data/db_logos/.gitkeep +0 -0
  17. package/server/routes/connections.js +2 -0
  18. package/server/server.js +7 -5
  19. package/server/services/sqlite/connectionManager.js +68 -33
  20. package/server/services/storage/appStateStore.js +159 -20
  21. package/server/utils/appPaths.js +42 -18
  22. /package/{assets → frontend/assets}/images/logo.webp +0 -0
  23. /package/{assets → frontend/assets}/images/logo_extrasmall.webp +0 -0
  24. /package/{assets → frontend/assets}/images/logo_raw.png +0 -0
  25. /package/{assets → frontend/assets}/images/logo_small.webp +0 -0
  26. /package/{assets → frontend/assets}/mockups/connections.png +0 -0
  27. /package/{assets → frontend/assets}/mockups/data.png +0 -0
  28. /package/{assets → frontend/assets}/mockups/data_edit.png +0 -0
  29. /package/{assets → frontend/assets}/mockups/graph_visualize.png +0 -0
  30. /package/{assets → frontend/assets}/mockups/home.png +0 -0
  31. /package/{assets → frontend/assets}/mockups/overview.png +0 -0
  32. /package/{assets → frontend/assets}/mockups/sql_editor.png +0 -0
  33. /package/{assets → frontend/assets}/mockups/structure.png +0 -0
  34. /package/{index.html → frontend/index.html} +0 -0
  35. /package/{js → frontend/js}/api.js +0 -0
  36. /package/{js → frontend/js}/components/actionBar.js +0 -0
  37. /package/{js → frontend/js}/components/appShell.js +0 -0
  38. /package/{js → frontend/js}/components/badges.js +0 -0
  39. /package/{js → frontend/js}/components/bottomTabs.js +0 -0
  40. /package/{js → frontend/js}/components/dataGrid.js +0 -0
  41. /package/{js → frontend/js}/components/metricCard.js +0 -0
  42. /package/{js → frontend/js}/components/pageHeader.js +0 -0
  43. /package/{js → frontend/js}/components/queryEditor.js +0 -0
  44. /package/{js → frontend/js}/components/queryResults.js +0 -0
  45. /package/{js → frontend/js}/components/rowEditorPanel.js +0 -0
  46. /package/{js → frontend/js}/components/statusBar.js +0 -0
  47. /package/{js → frontend/js}/components/structureGraph.js +0 -0
  48. /package/{js → frontend/js}/components/toast.js +0 -0
  49. /package/{js → frontend/js}/components/topNav.js +0 -0
  50. /package/{js → frontend/js}/lib/cytoscapeRuntime.js +0 -0
  51. /package/{js → frontend/js}/router.js +0 -0
  52. /package/{js → frontend/js}/utils/format.js +0 -0
  53. /package/{js → frontend/js}/views/connections.js +0 -0
  54. /package/{js → frontend/js}/views/editor.js +0 -0
  55. /package/{js → frontend/js}/views/landing.js +0 -0
  56. /package/{js → frontend/js}/views/overview.js +0 -0
  57. /package/{js → frontend/js}/views/settings.js +0 -0
  58. /package/{styles → frontend/styles}/base.css +0 -0
  59. /package/{styles → frontend/styles}/components.css +0 -0
  60. /package/{styles → frontend/styles}/layout.css +0 -0
  61. /package/{styles → frontend/styles}/tokens.css +0 -0
  62. /package/{styles → frontend/styles}/views.css +0 -0
  63. /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
 
package/changelog.md CHANGED
@@ -1,3 +1,10 @@
1
+ # v0.4.0
2
+
3
+ - search in data
4
+ - shift + enter
5
+ - logos for databases
6
+ - clean up some chaotic code things :)
7
+
1
8
  # v0.3.2
2
9
 
3
10
  - delete rows
@@ -37,6 +37,8 @@ import {
37
37
  selectStructureEntry,
38
38
  setDataPage,
39
39
  setDataPageSize,
40
+ setDataSearchColumn,
41
+ setDataSearchQuery,
40
42
  setCurrentQuery,
41
43
  setEditorTab,
42
44
  setRoute,
@@ -115,6 +117,35 @@ function syncQueryEditorScroll(textarea) {
115
117
  highlightNode.style.transform = `translate(${-textarea.scrollLeft}px, ${-textarea.scrollTop}px)`;
116
118
  }
117
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
+
118
149
  function renderNotFoundView() {
119
150
  return {
120
151
  main: `
@@ -224,7 +255,9 @@ function restoreFocusedInputState(snapshot) {
224
255
  function renderApp(state) {
225
256
  const focusedInput = captureFocusedInputState();
226
257
  const { main, panel } = resolveView(state);
227
- const isLockedRoute = ["editor", "editorResults", "data"].includes(state.route.name);
258
+ const isLockedRoute = ["editor", "editorResults", "data", "structure"].includes(
259
+ state.route.name
260
+ );
228
261
 
229
262
  teardownStructureGraph();
230
263
  shellRefs.topNav.innerHTML = renderTopNav(state);
@@ -249,6 +282,11 @@ const router = createRouter((route) => {
249
282
  setRoute(route);
250
283
  });
251
284
 
285
+ async function executeEditorQueryAndNavigate() {
286
+ const success = await executeCurrentQuery();
287
+ router.navigate(success ? "/editor/results" : "/editor");
288
+ }
289
+
252
290
  async function handleAction(actionNode) {
253
291
  const { action } = actionNode.dataset;
254
292
 
@@ -301,8 +339,7 @@ async function handleAction(actionNode) {
301
339
  await createActiveConnectionBackup();
302
340
  return;
303
341
  case "execute-query": {
304
- const success = await executeCurrentQuery();
305
- router.navigate(success ? "/editor/results" : "/editor");
342
+ await executeEditorQueryAndNavigate();
306
343
  return;
307
344
  }
308
345
  case "delete-data-row":
@@ -385,6 +422,27 @@ document.addEventListener("click", (event) => {
385
422
  });
386
423
 
387
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
+
388
446
  if (event.key !== "Escape" || event.defaultPrevented) {
389
447
  return;
390
448
  }
@@ -420,6 +478,11 @@ document.addEventListener("input", (event) => {
420
478
  syncQueryEditorHighlight(bindNode);
421
479
  syncQueryEditorScroll(bindNode);
422
480
  setCurrentQuery(bindNode.value);
481
+ return;
482
+ }
483
+
484
+ if (bindNode.dataset.bind === "data-search-query") {
485
+ setDataSearchQuery(bindNode.value);
423
486
  }
424
487
  });
425
488
 
@@ -447,6 +510,11 @@ document.addEventListener("change", (event) => {
447
510
  if (bindNode.dataset.bind === "history-entry" && bindNode.value) {
448
511
  loadQueryFromHistory(bindNode.value);
449
512
  bindNode.value = "";
513
+ return;
514
+ }
515
+
516
+ if (bindNode.dataset.bind === "data-search-column") {
517
+ setDataSearchColumn(bindNode.value);
450
518
  }
451
519
  });
452
520
 
@@ -512,6 +580,8 @@ document.addEventListener("submit", async (event) => {
512
580
  case "edit-connection": {
513
581
  const connectionId = String(formData.get("connectionId") ?? "");
514
582
  const isActiveConnection = getState().connections.active?.id === connectionId;
583
+ const logoFile = formData.get("logoFile");
584
+ const logoUpload = await buildConnectionLogoUpload(logoFile);
515
585
 
516
586
  if (isActiveConnection) {
517
587
  resetStructureGraphForDatabaseChange();
@@ -521,6 +591,8 @@ document.addEventListener("submit", async (event) => {
521
591
  path: String(formData.get("path") ?? ""),
522
592
  label: String(formData.get("label") ?? ""),
523
593
  readOnly: formData.get("readOnly") === "on",
594
+ clearLogo: formData.get("clearLogo") === "on" && !logoUpload,
595
+ logoUpload,
524
596
  });
525
597
 
526
598
  return;
@@ -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",
@@ -1,4 +1,5 @@
1
1
  import { escapeHtml } from "../utils/format.js";
2
+ import { renderConnectionLogo } from "./connectionLogo.js";
2
3
 
3
4
  const sidebarItems = [
4
5
  { label: "Connections", href: "#/connections", key: "connections", icon: "database" },
@@ -40,9 +41,13 @@ export function renderSidebar(state) {
40
41
  </nav>
41
42
  <div class="sidebar-footer">
42
43
  <div class="sidebar-footer-card">
43
- <div class="sidebar-footer-mark">
44
- <span class="material-symbols-outlined text-[15px]">memory</span>
45
- </div>
44
+ ${renderConnectionLogo(activeConnection, {
45
+ containerClass:
46
+ "sidebar-footer-mark overflow-hidden bg-primary-container text-on-primary",
47
+ imageClassName: "h-full w-full object-cover",
48
+ iconClassName: "text-[15px]",
49
+ icon: "memory",
50
+ })}
46
51
  <div class="min-w-0">
47
52
  <p class="truncate text-[10px] font-bold text-on-surface">
48
53
  ${escapeHtml(activeConnection?.label ?? "NO_ACTIVE_DATABASE")}
@@ -48,6 +48,8 @@ const state = {
48
48
  deleting: false,
49
49
  page: 1,
50
50
  pageSize: 50,
51
+ searchQuery: "",
52
+ searchColumn: "",
51
53
  selectedRowIndex: null,
52
54
  exportLoading: false,
53
55
  error: null,
@@ -118,6 +120,11 @@ function canEditQueryResult(snapshot = state) {
118
120
  return Boolean(snapshot.editor.result?.editing?.enabled) && !snapshot.connections.active?.readOnly;
119
121
  }
120
122
 
123
+ function resetDataBrowserSearch() {
124
+ state.dataBrowser.searchQuery = "";
125
+ state.dataBrowser.searchColumn = "";
126
+ }
127
+
121
128
  function buildUpdatedEditorResultRow(existingRow, updatedSourceRow, editableColumns) {
122
129
  const nextRow = {
123
130
  ...existingRow,
@@ -188,6 +195,7 @@ function setMissingDatabaseState() {
188
195
  state.dataBrowser.selectedTable = null;
189
196
  state.dataBrowser.table = null;
190
197
  state.dataBrowser.page = 1;
198
+ resetDataBrowserSearch();
191
199
  state.dataBrowser.selectedRowIndex = null;
192
200
  state.dataBrowser.exportLoading = false;
193
201
  state.dataBrowser.error = error;
@@ -221,6 +229,9 @@ function syncRouteContext() {
221
229
  if (route.name !== "data" || route.params?.tableName !== state.dataBrowser.selectedTable) {
222
230
  state.dataBrowser.page = 1;
223
231
  }
232
+ if (route.params?.tableName !== state.dataBrowser.selectedTable) {
233
+ resetDataBrowserSearch();
234
+ }
224
235
  state.dataBrowser.selectedRowIndex = null;
225
236
  state.dataBrowser.saveError = null;
226
237
  }
@@ -346,6 +357,9 @@ async function loadDataTable(version) {
346
357
  state.dataBrowser.table = response.data ?? null;
347
358
  state.dataBrowser.pageSize = pageSize;
348
359
  state.dataBrowser.page = response.data?.page ?? page;
360
+ state.dataBrowser.searchColumn = response.data?.columns?.includes(state.dataBrowser.searchColumn)
361
+ ? state.dataBrowser.searchColumn
362
+ : (response.data?.columns?.[0] ?? "");
349
363
  state.dataBrowser.selectedRowIndex = null;
350
364
  } catch (error) {
351
365
  if (version !== routeLoadVersion) {
@@ -383,6 +397,7 @@ async function loadData(version, route) {
383
397
  if (requestedTableName && tables.some((table) => table.name === requestedTableName)) {
384
398
  if (requestedTableName !== state.dataBrowser.selectedTable) {
385
399
  state.dataBrowser.page = 1;
400
+ resetDataBrowserSearch();
386
401
  }
387
402
  state.dataBrowser.selectedTable = requestedTableName;
388
403
  } else if (
@@ -391,10 +406,12 @@ async function loadData(version, route) {
391
406
  ) {
392
407
  state.dataBrowser.selectedTable = tables[0]?.name ?? null;
393
408
  state.dataBrowser.page = 1;
409
+ resetDataBrowserSearch();
394
410
  }
395
411
 
396
412
  if (!state.dataBrowser.selectedTable) {
397
413
  state.dataBrowser.table = null;
414
+ resetDataBrowserSearch();
398
415
  state.dataBrowser.selectedRowIndex = null;
399
416
  return;
400
417
  }
@@ -510,6 +527,7 @@ function invalidateDatabaseCaches() {
510
527
  state.dataBrowser.selectedTable = null;
511
528
  state.dataBrowser.table = null;
512
529
  state.dataBrowser.page = 1;
530
+ resetDataBrowserSearch();
513
531
  state.dataBrowser.selectedRowIndex = null;
514
532
  state.dataBrowser.exportLoading = false;
515
533
  state.dataBrowser.error = null;
@@ -1010,6 +1028,20 @@ export function clearDataRowSelection() {
1010
1028
  emitChange();
1011
1029
  }
1012
1030
 
1031
+ export function setDataSearchQuery(query) {
1032
+ state.dataBrowser.searchQuery = String(query ?? "");
1033
+ state.dataBrowser.selectedRowIndex = null;
1034
+ state.dataBrowser.saveError = null;
1035
+ emitChange();
1036
+ }
1037
+
1038
+ export function setDataSearchColumn(columnName) {
1039
+ state.dataBrowser.searchColumn = String(columnName ?? "");
1040
+ state.dataBrowser.selectedRowIndex = null;
1041
+ state.dataBrowser.saveError = null;
1042
+ emitChange();
1043
+ }
1044
+
1013
1045
  export async function setDataPage(page) {
1014
1046
  const numericPage = Number(page);
1015
1047
 
@@ -168,6 +168,80 @@ function getCellWidthClass(columnName) {
168
168
  return "max-w-[12rem]";
169
169
  }
170
170
 
171
+ function getFilteredTableRows(table, state) {
172
+ const allRows = table?.rows ?? [];
173
+ const availableColumns = table?.columns ?? [];
174
+ const searchQuery = String(state.dataBrowser.searchQuery ?? "").trim().toLowerCase();
175
+ const activeColumn = availableColumns.includes(state.dataBrowser.searchColumn)
176
+ ? state.dataBrowser.searchColumn
177
+ : (availableColumns[0] ?? "");
178
+
179
+ const indexedRows = allRows.map((row, index) => ({
180
+ row,
181
+ index,
182
+ }));
183
+
184
+ if (!searchQuery || !activeColumn) {
185
+ return {
186
+ activeColumn,
187
+ filteredRows: indexedRows,
188
+ searchQuery,
189
+ };
190
+ }
191
+
192
+ return {
193
+ activeColumn,
194
+ searchQuery,
195
+ filteredRows: indexedRows.filter(({ row }) =>
196
+ formatCellValue(row[activeColumn]).toLowerCase().includes(searchQuery)
197
+ ),
198
+ };
199
+ }
200
+
201
+ function renderTableSearchBar(table, state, activeColumn, filteredRowCount) {
202
+ const columns = table?.columns ?? [];
203
+
204
+ if (!table || !columns.length) {
205
+ return "";
206
+ }
207
+
208
+ return `
209
+ <div class="flex flex-wrap items-center gap-3 border-b border-outline-variant/10 bg-surface-container-low px-6 py-4">
210
+ <label class="flex min-w-[18rem] flex-1 items-center gap-3 border border-outline-variant/20 bg-surface-container-lowest px-4 py-3">
211
+ <span class="material-symbols-outlined text-base text-on-surface-variant/55">search</span>
212
+ <input
213
+ class="min-w-0 flex-1 bg-transparent text-sm text-on-surface outline-none placeholder:text-on-surface-variant/40"
214
+ data-bind="data-search-query"
215
+ placeholder="Filter current page..."
216
+ type="search"
217
+ value="${escapeHtml(state.dataBrowser.searchQuery ?? "")}"
218
+ />
219
+ </label>
220
+ <select
221
+ class="min-w-[14rem] border border-outline-variant/20 bg-surface-container-lowest px-4 py-3 font-mono text-xs tracking-[0.04em] text-on-surface outline-none"
222
+ data-bind="data-search-column"
223
+ >
224
+ ${columns
225
+ .map(
226
+ (columnName) => `
227
+ <option value="${escapeHtml(columnName)}" ${
228
+ columnName === activeColumn ? "selected" : ""
229
+ }>
230
+ ${escapeHtml(columnName)}
231
+ </option>
232
+ `
233
+ )
234
+ .join("")}
235
+ </select>
236
+ <div class="text-[10px] font-mono tracking-[0.14em] text-on-surface-variant/55">
237
+ ${escapeHtml(formatNumber(filteredRowCount))} match${
238
+ filteredRowCount === 1 ? "" : "es"
239
+ } on this page
240
+ </div>
241
+ </div>
242
+ `;
243
+ }
244
+
171
245
  function renderTableSurface(state) {
172
246
  const table = state.dataBrowser.table;
173
247
 
@@ -197,10 +271,11 @@ function renderTableSurface(state) {
197
271
  `;
198
272
  }
199
273
 
274
+ const { activeColumn, filteredRows, searchQuery } = getFilteredTableRows(table, state);
200
275
  const columns = (table.columns ?? []).map((columnName) => ({
201
276
  label: escapeHtml(columnName),
202
277
  headerClassName:
203
- "border-b border-primary-container/20 px-4 py-3 text-[10px] font-bold uppercase tracking-[0.16em] text-primary-container",
278
+ "border-b border-primary-container/20 px-4 py-3 text-[10px] font-bold tracking-[0.08em] text-primary-container",
204
279
  cellClassName: "px-4 py-2 align-top text-[11px] text-on-surface",
205
280
  render: (row) => {
206
281
  const value = formatCellValue(row[columnName]);
@@ -219,25 +294,35 @@ function renderTableSurface(state) {
219
294
  const fromRow = totalRows === 0 ? 0 : (table.offset ?? 0) + 1;
220
295
  const toRow = totalRows === 0 ? 0 : Math.min((table.offset ?? 0) + (table.rows?.length ?? 0), totalRows);
221
296
  const pageSizes = [25, 50, 100];
297
+ const filteredRowCount = filteredRows.length;
298
+ const hasActiveSearch = Boolean(searchQuery);
222
299
 
223
300
  return `
224
301
  <div class="flex flex-1 min-h-0 flex-col bg-surface-container-lowest">
302
+ ${renderTableSearchBar(table, state, activeColumn, filteredRowCount)}
225
303
  <div class="custom-scrollbar flex-1 overflow-auto">
226
304
  ${renderDataGrid({
227
305
  columns,
228
- rows: table.rows ?? [],
306
+ rows: filteredRows.map(({ row }) => row),
229
307
  tableClass: "min-w-full border-collapse text-left font-mono text-xs",
230
308
  theadClass: "sticky top-0 z-10 bg-surface-container-highest",
231
309
  tbodyClass: "divide-y divide-outline-variant/5",
232
- getRowClass: (_, index) =>
233
- `${
234
- state.dataBrowser.selectedRowIndex === index
310
+ getRowClass: (_, filteredIndex) => {
311
+ const rowIndex = filteredRows[filteredIndex]?.index ?? filteredIndex;
312
+
313
+ return `${
314
+ state.dataBrowser.selectedRowIndex === rowIndex
235
315
  ? "bg-surface-bright"
236
- : index % 2 === 0
316
+ : filteredIndex % 2 === 0
237
317
  ? "bg-surface-container-low"
238
318
  : "bg-surface-container-lowest"
239
- } cursor-pointer transition-colors hover:bg-surface-container-high`,
240
- getRowAttrs: (_, index) => `data-action="select-data-row" data-row-index="${index}"`,
319
+ } cursor-pointer transition-colors hover:bg-surface-container-high`;
320
+ },
321
+ getRowAttrs: (_, filteredIndex) => {
322
+ const rowIndex = filteredRows[filteredIndex]?.index ?? filteredIndex;
323
+
324
+ return `data-action="select-data-row" data-row-index="${rowIndex}"`;
325
+ },
241
326
  })}
242
327
  ${
243
328
  !table.rows?.length
@@ -248,6 +333,14 @@ function renderTableSurface(state) {
248
333
  </p>
249
334
  </div>
250
335
  `
336
+ : !filteredRowCount
337
+ ? `
338
+ <div class="flex min-h-[180px] items-center justify-center border-t border-outline-variant/10">
339
+ <p class="font-mono text-[10px] tracking-[0.18em] text-on-surface-variant/40">
340
+ ${hasActiveSearch ? "No matching rows on this page." : "No rows available."}
341
+ </p>
342
+ </div>
343
+ `
251
344
  : ""
252
345
  }
253
346
  </div>
@@ -255,7 +348,7 @@ function renderTableSurface(state) {
255
348
  <div class="text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">
256
349
  showing ${escapeHtml(formatNumber(fromRow))}-${escapeHtml(formatNumber(toRow))} of ${escapeHtml(
257
350
  formatNumber(totalRows)
258
- )} rows
351
+ )} rows${hasActiveSearch ? ` // ${escapeHtml(formatNumber(filteredRowCount))} visible on this page` : ""}
259
352
  </div>
260
353
  <div class="flex flex-wrap items-center gap-4">
261
354
  <div class="flex items-center gap-2">