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.
- package/README.md +5 -5
- package/changelog.md +7 -0
- package/{js → frontend/js}/app.js +75 -3
- package/{js → frontend/js}/components/connectionCard.js +8 -9
- package/frontend/js/components/connectionLogo.js +33 -0
- package/{js → frontend/js}/components/emptyState.js +25 -11
- package/{js → frontend/js}/components/modal.js +57 -0
- package/{js → frontend/js}/components/sidebar.js +8 -3
- package/{js → frontend/js}/store.js +32 -0
- package/{js → frontend/js}/views/data.js +102 -9
- package/{js → frontend/js}/views/structure.js +10 -12
- package/{styles → frontend/styles}/structure-graph.css +5 -10
- package/package.json +2 -2
- package/{publish_brew.sh → scripts/publish_brew.sh} +2 -2
- package/{publish_npm.sh → scripts/publish_npm.sh} +2 -2
- package/server/data/db_logos/.gitkeep +0 -0
- package/server/routes/connections.js +2 -0
- package/server/server.js +7 -5
- package/server/services/sqlite/connectionManager.js +68 -33
- package/server/services/storage/appStateStore.js +159 -20
- package/server/utils/appPaths.js +42 -18
- /package/{assets → frontend/assets}/images/logo.webp +0 -0
- /package/{assets → frontend/assets}/images/logo_extrasmall.webp +0 -0
- /package/{assets → frontend/assets}/images/logo_raw.png +0 -0
- /package/{assets → frontend/assets}/images/logo_small.webp +0 -0
- /package/{assets → frontend/assets}/mockups/connections.png +0 -0
- /package/{assets → frontend/assets}/mockups/data.png +0 -0
- /package/{assets → frontend/assets}/mockups/data_edit.png +0 -0
- /package/{assets → frontend/assets}/mockups/graph_visualize.png +0 -0
- /package/{assets → frontend/assets}/mockups/home.png +0 -0
- /package/{assets → frontend/assets}/mockups/overview.png +0 -0
- /package/{assets → frontend/assets}/mockups/sql_editor.png +0 -0
- /package/{assets → frontend/assets}/mockups/structure.png +0 -0
- /package/{index.html → frontend/index.html} +0 -0
- /package/{js → frontend/js}/api.js +0 -0
- /package/{js → frontend/js}/components/actionBar.js +0 -0
- /package/{js → frontend/js}/components/appShell.js +0 -0
- /package/{js → frontend/js}/components/badges.js +0 -0
- /package/{js → frontend/js}/components/bottomTabs.js +0 -0
- /package/{js → frontend/js}/components/dataGrid.js +0 -0
- /package/{js → frontend/js}/components/metricCard.js +0 -0
- /package/{js → frontend/js}/components/pageHeader.js +0 -0
- /package/{js → frontend/js}/components/queryEditor.js +0 -0
- /package/{js → frontend/js}/components/queryResults.js +0 -0
- /package/{js → frontend/js}/components/rowEditorPanel.js +0 -0
- /package/{js → frontend/js}/components/statusBar.js +0 -0
- /package/{js → frontend/js}/components/structureGraph.js +0 -0
- /package/{js → frontend/js}/components/toast.js +0 -0
- /package/{js → frontend/js}/components/topNav.js +0 -0
- /package/{js → frontend/js}/lib/cytoscapeRuntime.js +0 -0
- /package/{js → frontend/js}/router.js +0 -0
- /package/{js → frontend/js}/utils/format.js +0 -0
- /package/{js → frontend/js}/views/connections.js +0 -0
- /package/{js → frontend/js}/views/editor.js +0 -0
- /package/{js → frontend/js}/views/landing.js +0 -0
- /package/{js → frontend/js}/views/overview.js +0 -0
- /package/{js → frontend/js}/views/settings.js +0 -0
- /package/{styles → frontend/styles}/base.css +0 -0
- /package/{styles → frontend/styles}/components.css +0 -0
- /package/{styles → frontend/styles}/layout.css +0 -0
- /package/{styles → frontend/styles}/tokens.css +0 -0
- /package/{styles → frontend/styles}/views.css +0 -0
- /package/{data → server/data}/.gitkeep +0 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# sqlite-hub ⚡️
|
|
2
2
|
|
|
3
|
-

|
|
3
|
+

|
|
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
|
-

|
|
27
|
+

|
|
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
|
-

|
|
33
|
+

|
|
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
|
-

|
|
39
|
+

|
|
40
40
|
|
|
41
41
|
Open one record, edit it in place, commit, continue.
|
|
42
42
|
|
|
43
43
|
### SQL editor
|
|
44
44
|
|
|
45
|
-

|
|
45
|
+

|
|
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
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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:
|
|
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: (_,
|
|
233
|
-
|
|
234
|
-
|
|
310
|
+
getRowClass: (_, filteredIndex) => {
|
|
311
|
+
const rowIndex = filteredRows[filteredIndex]?.index ?? filteredIndex;
|
|
312
|
+
|
|
313
|
+
return `${
|
|
314
|
+
state.dataBrowser.selectedRowIndex === rowIndex
|
|
235
315
|
? "bg-surface-bright"
|
|
236
|
-
:
|
|
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
|
-
|
|
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">
|