sqlite-hub 0.16.0 → 0.17.2

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 (53) hide show
  1. package/README.md +106 -19
  2. package/bin/sqlite-hub.js +1041 -224
  3. package/frontend/index.html +1 -0
  4. package/frontend/js/api.js +34 -0
  5. package/frontend/js/app.js +481 -32
  6. package/frontend/js/components/modal.js +270 -50
  7. package/frontend/js/components/pageHeader.js +14 -16
  8. package/frontend/js/components/queryHistoryPanel.js +126 -131
  9. package/frontend/js/components/queryResults.js +18 -1
  10. package/frontend/js/components/rowEditorPanel.js +42 -34
  11. package/frontend/js/components/sidebar.js +1 -0
  12. package/frontend/js/router.js +8 -0
  13. package/frontend/js/store.js +655 -2
  14. package/frontend/js/utils/jsonPreview.js +31 -0
  15. package/frontend/js/utils/markdownDocuments.js +248 -0
  16. package/frontend/js/utils/rowEditorValues.js +41 -0
  17. package/frontend/js/utils/tableScrollState.js +39 -0
  18. package/frontend/js/views/charts.js +8 -0
  19. package/frontend/js/views/data.js +4 -1
  20. package/frontend/js/views/documents.js +300 -0
  21. package/frontend/js/views/editor.js +2 -0
  22. package/frontend/js/views/settings.js +39 -3
  23. package/frontend/styles/components.css +19 -0
  24. package/frontend/styles/tailwind.generated.css +1 -1
  25. package/frontend/styles/views.css +422 -0
  26. package/package.json +2 -1
  27. package/server/middleware/localRequestSecurity.js +84 -0
  28. package/server/routes/connections.js +18 -1
  29. package/server/routes/documents.js +163 -0
  30. package/server/routes/settings.js +22 -6
  31. package/server/server.js +18 -1
  32. package/server/services/nativeFileDialogService.js +168 -0
  33. package/server/services/sqlite/dataBrowserService.js +0 -4
  34. package/server/services/sqlite/exportService.js +5 -1
  35. package/server/services/sqlite/sqlExecutor.js +51 -2
  36. package/server/services/storage/appStateStore.js +313 -0
  37. package/server/utils/sqliteTypes.js +17 -8
  38. package/tests/cli-args.test.js +100 -0
  39. package/tests/connections-file-dialog-route.test.js +46 -0
  40. package/tests/copy-column-modal.test.js +97 -0
  41. package/tests/database-documents.test.js +85 -0
  42. package/tests/export-blob.test.js +46 -0
  43. package/tests/json-preview.test.js +49 -0
  44. package/tests/local-request-security.test.js +85 -0
  45. package/tests/markdown-documents.test.js +79 -0
  46. package/tests/native-file-dialog.test.js +78 -0
  47. package/tests/query-results-truncation.test.js +20 -0
  48. package/tests/row-editor-null-values.test.js +131 -0
  49. package/tests/settings-metadata.test.js +16 -0
  50. package/tests/settings-view.test.js +32 -0
  51. package/tests/sql-identifier-safety.test.js +9 -3
  52. package/tests/sql-result-limit.test.js +66 -0
  53. package/tests/table-scroll-state.test.js +70 -0
@@ -0,0 +1,31 @@
1
+ export function formatJsonPreview(value) {
2
+ if (value === null || value === undefined) {
3
+ return null;
4
+ }
5
+
6
+ let parsed = value;
7
+
8
+ if (typeof value !== "object") {
9
+ const text = String(value).trim();
10
+
11
+ if (!text || !["{", "["].includes(text[0])) {
12
+ return null;
13
+ }
14
+
15
+ try {
16
+ parsed = JSON.parse(text);
17
+ } catch (error) {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ if (!parsed || typeof parsed !== "object") {
23
+ return null;
24
+ }
25
+
26
+ try {
27
+ return JSON.stringify(parsed, null, 2);
28
+ } catch (error) {
29
+ return null;
30
+ }
31
+ }
@@ -0,0 +1,248 @@
1
+ import { escapeHtml } from './format.js';
2
+
3
+ const FENCED_CODE_PATTERN = /^\s*(```|~~~)/;
4
+ const TASK_LINE_PATTERN = /^(\s*)([-*+])\s+\[([ xX])\]\s+(.*?)(\r?)$/;
5
+
6
+ function decodeBasicHtmlEntities(value = '') {
7
+ return String(value)
8
+ .replace(/:/gi, ':')
9
+ .replace(/:/g, ':')
10
+ .replace(/:/gi, ':')
11
+ .replace(/&/gi, '&')
12
+ .replace(/"/gi, '"')
13
+ .replace(/'/gi, "'");
14
+ }
15
+
16
+ function sanitizeRenderedMarkdown(html = '') {
17
+ return String(html).replace(/\s(href|src)=("([^"]*)"|'([^']*)'|([^\s>]+))/gi, (match, attribute, raw, doubleQuoted, singleQuoted, bare) => {
18
+ const value = doubleQuoted ?? singleQuoted ?? bare ?? '';
19
+ const decodedValue = decodeBasicHtmlEntities(value).trim();
20
+
21
+ if (/^(javascript|data|vbscript):/i.test(decodedValue)) {
22
+ return ` ${attribute}="#"`;
23
+ }
24
+
25
+ return match;
26
+ });
27
+ }
28
+
29
+ function renderFallbackMarkdown(markdown = '') {
30
+ const lines = String(markdown).split(/\r?\n/);
31
+ const html = [];
32
+ let listItems = [];
33
+ let listTag = 'ul';
34
+
35
+ const flushList = () => {
36
+ if (!listItems.length) {
37
+ return;
38
+ }
39
+
40
+ html.push(`<${listTag}>${listItems.map(item => `<li>${item}</li>`).join('')}</${listTag}>`);
41
+ listItems = [];
42
+ listTag = 'ul';
43
+ };
44
+
45
+ for (const line of lines) {
46
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
47
+ const unorderedMatch = line.match(/^\s*[-*+]\s+(.+)$/);
48
+ const orderedMatch = line.match(/^\s*\d+[.)]\s+(.+)$/);
49
+
50
+ if (headingMatch) {
51
+ flushList();
52
+ const level = Math.min(6, headingMatch[1].length);
53
+ html.push(`<h${level}>${escapeHtml(headingMatch[2])}</h${level}>`);
54
+ continue;
55
+ }
56
+
57
+ if (unorderedMatch) {
58
+ if (listItems.length && listTag !== 'ul') {
59
+ flushList();
60
+ }
61
+ listTag = 'ul';
62
+ listItems.push(escapeHtml(unorderedMatch[1]));
63
+ continue;
64
+ }
65
+
66
+ if (orderedMatch) {
67
+ if (listItems.length && listTag !== 'ol') {
68
+ flushList();
69
+ }
70
+ listTag = 'ol';
71
+ listItems.push(escapeHtml(orderedMatch[1]));
72
+ continue;
73
+ }
74
+
75
+ flushList();
76
+
77
+ if (line.trim()) {
78
+ html.push(`<p>${escapeHtml(line)}</p>`);
79
+ }
80
+ }
81
+
82
+ flushList();
83
+ return html.join('\n');
84
+ }
85
+
86
+ function renderMarkdownBlock(markdown = '') {
87
+ const source = escapeHtml(markdown);
88
+ const markedRuntime = globalThis.marked;
89
+ const parser = typeof markedRuntime?.parse === 'function' ? markedRuntime.parse.bind(markedRuntime) : null;
90
+
91
+ if (!parser) {
92
+ return renderFallbackMarkdown(markdown);
93
+ }
94
+
95
+ return sanitizeRenderedMarkdown(
96
+ parser(source, {
97
+ async: false,
98
+ breaks: false,
99
+ gfm: true,
100
+ }),
101
+ );
102
+ }
103
+
104
+ function renderMarkdownInline(markdown = '') {
105
+ const source = escapeHtml(markdown);
106
+ const markedRuntime = globalThis.marked;
107
+ const parser =
108
+ typeof markedRuntime?.parseInline === 'function' ? markedRuntime.parseInline.bind(markedRuntime) : null;
109
+
110
+ if (!parser) {
111
+ return source;
112
+ }
113
+
114
+ return sanitizeRenderedMarkdown(
115
+ parser(source, {
116
+ async: false,
117
+ breaks: false,
118
+ gfm: true,
119
+ }),
120
+ );
121
+ }
122
+
123
+ function renderTaskList(items = []) {
124
+ if (!items.length) {
125
+ return '';
126
+ }
127
+
128
+ return `
129
+ <ul class="document-markdown-task-list">
130
+ ${items
131
+ .map(item => {
132
+ const checked = item.checked;
133
+
134
+ return `
135
+ <li class="document-markdown-task-item ${checked ? 'is-checked' : ''}">
136
+ <label class="document-markdown-task-label">
137
+ <input
138
+ ${checked ? 'checked' : ''}
139
+ class="document-markdown-task-checkbox"
140
+ data-action="toggle-document-todo"
141
+ data-line-index="${escapeHtml(String(item.lineIndex))}"
142
+ type="checkbox"
143
+ />
144
+ <span class="document-markdown-task-text">${renderMarkdownInline(item.text)}</span>
145
+ </label>
146
+ </li>
147
+ `;
148
+ })
149
+ .join('')}
150
+ </ul>
151
+ `;
152
+ }
153
+
154
+ export function renderMarkdownPreview(markdown = '') {
155
+ const lines = String(markdown ?? '').split('\n');
156
+ const blocks = [];
157
+ let markdownLines = [];
158
+ let taskItems = [];
159
+ let inFence = false;
160
+
161
+ const flushMarkdown = () => {
162
+ if (!markdownLines.length) {
163
+ return;
164
+ }
165
+
166
+ blocks.push(renderMarkdownBlock(markdownLines.join('\n')));
167
+ markdownLines = [];
168
+ };
169
+
170
+ const flushTasks = () => {
171
+ if (!taskItems.length) {
172
+ return;
173
+ }
174
+
175
+ blocks.push(renderTaskList(taskItems));
176
+ taskItems = [];
177
+ };
178
+
179
+ lines.forEach((line, lineIndex) => {
180
+ const fenceLine = FENCED_CODE_PATTERN.test(line);
181
+
182
+ if (fenceLine) {
183
+ flushTasks();
184
+ markdownLines.push(line);
185
+ inFence = !inFence;
186
+ return;
187
+ }
188
+
189
+ if (!inFence) {
190
+ const taskMatch = line.match(TASK_LINE_PATTERN);
191
+
192
+ if (taskMatch) {
193
+ flushMarkdown();
194
+ taskItems.push({
195
+ checked: taskMatch[3].toLowerCase() === 'x',
196
+ lineIndex,
197
+ text: taskMatch[4],
198
+ });
199
+ return;
200
+ }
201
+ }
202
+
203
+ flushTasks();
204
+ markdownLines.push(line);
205
+ });
206
+
207
+ flushTasks();
208
+ flushMarkdown();
209
+
210
+ return blocks.join('\n').trim() || '<p class="document-markdown-empty">Empty document</p>';
211
+ }
212
+
213
+ function isLineInsideFencedCode(lines, lineIndex) {
214
+ let inFence = false;
215
+
216
+ for (let index = 0; index < lineIndex; index += 1) {
217
+ if (FENCED_CODE_PATTERN.test(lines[index] ?? '')) {
218
+ inFence = !inFence;
219
+ }
220
+ }
221
+
222
+ return inFence;
223
+ }
224
+
225
+ export function toggleMarkdownTodoLine(markdown = '', lineIndex) {
226
+ const lines = String(markdown ?? '').split('\n');
227
+ const numericLineIndex = Number(lineIndex);
228
+
229
+ if (!Number.isInteger(numericLineIndex) || numericLineIndex < 0 || numericLineIndex >= lines.length) {
230
+ return String(markdown ?? '');
231
+ }
232
+
233
+ if (isLineInsideFencedCode(lines, numericLineIndex)) {
234
+ return String(markdown ?? '');
235
+ }
236
+
237
+ const line = lines[numericLineIndex];
238
+ const match = line.match(TASK_LINE_PATTERN);
239
+
240
+ if (!match) {
241
+ return String(markdown ?? '');
242
+ }
243
+
244
+ const nextMarker = match[3].toLowerCase() === 'x' ? ' ' : 'x';
245
+ lines[numericLineIndex] = `${match[1]}${match[2]} [${nextMarker}] ${match[4]}${match[5]}`;
246
+
247
+ return lines.join('\n');
248
+ }
@@ -0,0 +1,41 @@
1
+ export function getRowEditorValueState(value) {
2
+ if (value === null || value === undefined) {
3
+ return "null";
4
+ }
5
+
6
+ return String(value) === "" ? "empty" : "value";
7
+ }
8
+
9
+ export function getRowEditorValueStateLabel(state) {
10
+ if (state === "null") {
11
+ return "NULL";
12
+ }
13
+
14
+ if (state === "empty") {
15
+ return "EMPTY STRING";
16
+ }
17
+
18
+ return "VALUE";
19
+ }
20
+
21
+ export function buildRowEditorSubmittedValues(formData, fieldMetadata = {}) {
22
+ const fieldNames = new Set();
23
+
24
+ for (const [key] of formData.entries()) {
25
+ if (key.startsWith("field:")) {
26
+ fieldNames.add(key.slice("field:".length));
27
+ }
28
+ }
29
+
30
+ return Object.fromEntries(
31
+ [...fieldNames].map((fieldName) => {
32
+ const valueKey = `field:${fieldName}`;
33
+ const value = String(formData.get(valueKey) ?? "");
34
+ const metadata = fieldMetadata[fieldName] ?? {};
35
+ const keepInitialNull =
36
+ metadata.initialState === "null" && metadata.dirty !== true && value === "";
37
+
38
+ return [fieldName, keepInitialNull ? null : value];
39
+ })
40
+ );
41
+ }
@@ -0,0 +1,39 @@
1
+ export function captureTableHorizontalScrollState({ routeName = "", scrollNodes = [] } = {}) {
2
+ return {
3
+ routeName: String(routeName ?? ""),
4
+ positions: Array.from(scrollNodes)
5
+ .map((scrollNode) => ({
6
+ key: String(scrollNode?.dataset?.tableScrollKey ?? ""),
7
+ scrollLeft: Number(scrollNode?.scrollLeft) || 0,
8
+ }))
9
+ .filter((position) => position.key),
10
+ };
11
+ }
12
+
13
+ export function restoreTableHorizontalScrollState({
14
+ snapshot,
15
+ routeName = "",
16
+ scrollNodes = [],
17
+ } = {}) {
18
+ if (!snapshot || snapshot.routeName !== String(routeName ?? "")) {
19
+ return false;
20
+ }
21
+
22
+ const candidates = Array.from(scrollNodes);
23
+ let restored = false;
24
+
25
+ for (const position of snapshot.positions ?? []) {
26
+ const scrollNode = candidates.find(
27
+ (candidate) => String(candidate?.dataset?.tableScrollKey ?? "") === position.key
28
+ );
29
+
30
+ if (!scrollNode) {
31
+ continue;
32
+ }
33
+
34
+ scrollNode.scrollLeft = position.scrollLeft;
35
+ restored = true;
36
+ }
37
+
38
+ return restored;
39
+ }
@@ -220,6 +220,14 @@ function renderQueryResultState(state, result) {
220
220
  `;
221
221
  }
222
222
 
223
+ if (result.truncated) {
224
+ return `
225
+ <div class="border-b border-primary-container/20 bg-primary-container/10 px-4 py-3 text-sm text-on-surface">
226
+ Charts use the first ${escapeHtml(String(result.rowLimit ?? result.rows?.length ?? 0))} rows. Refine the query for a complete visualization.
227
+ </div>
228
+ `;
229
+ }
230
+
223
231
  return '';
224
232
  }
225
233
 
@@ -461,7 +461,9 @@ function renderTableSurface(state) {
461
461
  return [
462
462
  '<div class="flex flex-1 min-h-0 flex-col bg-surface-container-lowest">',
463
463
  renderTableFilterBar(table, state, activeColumn),
464
- '<div class="custom-scrollbar flex-1 overflow-auto">',
464
+ '<div class="custom-scrollbar flex-1 overflow-auto" data-table-horizontal-scroll data-table-scroll-key="data:',
465
+ escapeHtml(table.name),
466
+ '">',
465
467
  gridMarkup,
466
468
  emptyMarkup,
467
469
  '</div>',
@@ -603,6 +605,7 @@ export function renderDataRowEditorPanel(state) {
603
605
  ...getColumnNumberInputMeta(column),
604
606
  allowedValues: column.allowedValues ?? [],
605
607
  notNull: Boolean(column.notNull),
608
+ rawValue: value,
606
609
  value: value === null || value === undefined ? '' : String(value),
607
610
  };
608
611
  }),
@@ -0,0 +1,300 @@
1
+ import { escapeHtml, formatCompactDateTime, formatNumber } from '../utils/format.js';
2
+ import { renderMarkdownPreview } from '../utils/markdownDocuments.js';
3
+
4
+ function renderMissingDatabase() {
5
+ return `
6
+ <section class="view-surface">
7
+ <div class="view-frame flex min-h-full items-center justify-center">
8
+ <div class="text-center">
9
+ <span class="material-symbols-outlined mb-3 text-5xl text-on-surface-variant/25">database_off</span>
10
+ <p class="font-headline text-xl font-black uppercase tracking-tight text-primary-container">
11
+ No Active SQLite Database
12
+ </p>
13
+ <p class="mt-3 max-w-xl text-sm leading-7 text-on-surface-variant/65">
14
+ Select a database before opening its document folder.
15
+ </p>
16
+ </div>
17
+ </div>
18
+ </section>
19
+ `;
20
+ }
21
+
22
+ function renderDocumentListItem(item, selectedId) {
23
+ const isSelected = String(item.id) === String(selectedId);
24
+
25
+ return `
26
+ <button
27
+ class="documents-list-item ${isSelected ? 'is-selected' : ''}"
28
+ data-action="select-document"
29
+ data-document-id="${escapeHtml(item.id)}"
30
+ type="button"
31
+ >
32
+ <span class="material-symbols-outlined documents-list-item__icon">description</span>
33
+ <span class="documents-list-item__body">
34
+ <span class="documents-list-item__title" title="${escapeHtml(item.filename)}">
35
+ ${escapeHtml(item.filename)}
36
+ </span>
37
+ <span class="documents-list-item__meta">
38
+ ${escapeHtml(formatCompactDateTime(item.updatedAt))} // ${formatNumber(item.contentLength ?? 0)} chars
39
+ </span>
40
+ </span>
41
+ </button>
42
+ `;
43
+ }
44
+
45
+ function renderDocumentsSidebar(documents) {
46
+ const items = documents.items ?? [];
47
+
48
+ return `
49
+ <aside class="documents-view__sidebar">
50
+ <div class="documents-view__sidebar-header">
51
+ <div>
52
+ <div class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/50">
53
+ Documents
54
+ </div>
55
+ <div class="mt-1 font-mono text-xs text-primary-container">${formatNumber(items.length)} files</div>
56
+ </div>
57
+ <button
58
+ class="icon-button"
59
+ data-action="create-document"
60
+ title="New document"
61
+ type="button"
62
+ ${documents.saving ? 'disabled aria-disabled="true"' : ''}
63
+ >
64
+ <span class="material-symbols-outlined">add</span>
65
+ </button>
66
+ </div>
67
+ <div class="documents-view__sidebar-body custom-scrollbar">
68
+ ${
69
+ items.length
70
+ ? items.map(item => renderDocumentListItem(item, documents.selectedId)).join('')
71
+ : `<div class="documents-list-empty">No documents yet</div>`
72
+ }
73
+ </div>
74
+ </aside>
75
+ `;
76
+ }
77
+
78
+ function renderDocumentToolbar(documents) {
79
+ const disabled = documents.saving || documents.detailLoading || !documents.selectedId;
80
+
81
+ return ` <label class="documents-filename-field">
82
+ <input
83
+ class="control-input"
84
+ data-bind="document-field"
85
+ data-field="filename"
86
+ name="filename"
87
+ spellcheck="false"
88
+ type="text"
89
+ value="${escapeHtml(documents.draftFilename)}"
90
+ ${!documents.selectedId ? 'disabled' : ''}
91
+ />
92
+ </label>
93
+ <div class="documents-toolbar">
94
+
95
+
96
+
97
+ <div class="documents-toolbar__actions">
98
+ <button
99
+ class="standard-button"
100
+ data-action="toggle-document-pane"
101
+ data-pane="editor"
102
+ type="button"
103
+ >
104
+ <span class="material-symbols-outlined">${documents.editorVisible ? 'visibility_off' : 'edit_note'}</span>
105
+ ${documents.editorVisible ? 'Hide Editor' : 'Show Editor'}
106
+ </button>
107
+ <button
108
+ class="standard-button"
109
+ data-action="toggle-document-pane"
110
+ data-pane="preview"
111
+ type="button"
112
+ >
113
+ <span class="material-symbols-outlined">${documents.previewVisible ? 'visibility_off' : 'visibility'}</span>
114
+ ${documents.previewVisible ? 'Hide Preview' : 'Show Preview'}
115
+ </button>
116
+ <input
117
+ accept=".md,.markdown,text/markdown,text/plain"
118
+ data-bind="document-import-file"
119
+ hidden
120
+ type="file"
121
+ />
122
+ </div>
123
+ <div class="documents-toolbar__actions">
124
+ <button
125
+ class="standard-button"
126
+ data-action="open-document-insert-table-modal"
127
+ type="button"
128
+ ${disabled ? 'disabled aria-disabled="true"' : ''}
129
+ >
130
+ <span class="material-symbols-outlined">table_chart</span>
131
+ Insert Table
132
+ </button>
133
+ <button
134
+ class="standard-button"
135
+ data-action="open-document-insert-note-modal"
136
+ type="button"
137
+ ${disabled ? 'disabled aria-disabled="true"' : ''}
138
+ >
139
+ <span class="material-symbols-outlined">note_add</span>
140
+ Insert Note
141
+ </button>
142
+ <button
143
+ class="standard-button"
144
+ data-action="export-document-markdown"
145
+ type="button"
146
+ ${disabled ? 'disabled aria-disabled="true"' : ''}
147
+ >
148
+ <span class="material-symbols-outlined">download</span>
149
+ Export .md
150
+ </button>
151
+ <button
152
+ class="standard-button"
153
+ data-action="import-document-markdown"
154
+ type="button"
155
+ ${documents.saving ? 'disabled aria-disabled="true"' : ''}
156
+ >
157
+ <span class="material-symbols-outlined">upload_file</span>
158
+ Import .md
159
+ </button>
160
+ </div>
161
+ <div class="documents-toolbar__actions">
162
+ <button
163
+ class="standard-button"
164
+ data-action="delete-document"
165
+ type="button"
166
+ ${disabled || documents.deleting ? 'disabled aria-disabled="true"' : ''}
167
+ >
168
+ <span class="material-symbols-outlined">delete</span>
169
+ Delete
170
+ </button>
171
+ <button
172
+ class="signature-button"
173
+ data-action="save-document"
174
+ type="button"
175
+ ${disabled || !documents.dirty ? 'disabled aria-disabled="true"' : ''}
176
+ >
177
+ <span class="material-symbols-outlined">save</span>
178
+ ${documents.saving ? 'Saving...' : 'Save'}
179
+ </button>
180
+ </div>
181
+ </div>
182
+ `;
183
+ }
184
+
185
+ function renderDocumentEditor(documents) {
186
+ if (!documents.editorVisible) {
187
+ return '';
188
+ }
189
+
190
+ return `
191
+ <section class="documents-pane documents-pane--editor">
192
+ <div class="documents-pane__header">
193
+ <span>Editor</span>
194
+ <span>${formatNumber(documents.draftContent.length)} chars</span>
195
+ </div>
196
+ <textarea
197
+ class="documents-editor-input custom-scrollbar"
198
+ data-bind="document-field"
199
+ data-field="content"
200
+ name="content"
201
+ spellcheck="true"
202
+ ${!documents.selectedId ? 'disabled' : ''}
203
+ >${escapeHtml(documents.draftContent)}</textarea>
204
+ </section>
205
+ `;
206
+ }
207
+
208
+ function renderDocumentPreview(documents) {
209
+ if (!documents.previewVisible) {
210
+ return '';
211
+ }
212
+
213
+ return `
214
+ <section class="documents-pane documents-pane--preview">
215
+ <div class="documents-pane__header">
216
+ <span>Preview</span>
217
+ <span>${escapeHtml(documents.dirty ? 'unsaved' : 'saved')}</span>
218
+ </div>
219
+ <div class="document-markdown-preview custom-scrollbar" data-document-preview>
220
+ ${renderMarkdownPreview(documents.draftContent)}
221
+ </div>
222
+ </section>
223
+ `;
224
+ }
225
+
226
+ function renderEmptyDocumentsState(documents) {
227
+ return `
228
+ <div class="documents-empty-state">
229
+ <span class="material-symbols-outlined">description</span>
230
+ <p class="font-headline text-2xl font-black uppercase tracking-tight text-primary-container">
231
+ No Documents
232
+ </p>
233
+ <button
234
+ class="signature-button mt-4"
235
+ data-action="create-document"
236
+ type="button"
237
+ ${documents.saving ? 'disabled aria-disabled="true"' : ''}
238
+ >
239
+ <span class="material-symbols-outlined">add</span>
240
+ New Document
241
+ </button>
242
+ </div>
243
+ `;
244
+ }
245
+
246
+ function renderDocumentDetail(documents) {
247
+ if (documents.loading && !documents.items.length) {
248
+ return `
249
+ <main class="documents-view__detail">
250
+ <div class="documents-empty-state">
251
+ <span class="material-symbols-outlined">sync</span>
252
+ <p class="font-headline text-2xl font-black uppercase tracking-tight text-primary-container">
253
+ Loading Documents
254
+ </p>
255
+ </div>
256
+ </main>
257
+ `;
258
+ }
259
+
260
+ if (!documents.selectedId) {
261
+ return `
262
+ <main class="documents-view__detail">
263
+ ${renderEmptyDocumentsState(documents)}
264
+ </main>
265
+ `;
266
+ }
267
+
268
+ const paneCount = Number(documents.editorVisible) + Number(documents.previewVisible);
269
+
270
+ return `
271
+ <main class="documents-view__detail">
272
+ ${renderDocumentToolbar(documents)}
273
+ ${documents.saveError ? `<div class="documents-error">${escapeHtml(documents.saveError.message)}</div>` : ''}
274
+ <div class="documents-workspace ${paneCount > 1 ? 'documents-workspace--split' : ''}">
275
+ ${renderDocumentEditor(documents)}
276
+ ${renderDocumentPreview(documents)}
277
+ </div>
278
+ </main>
279
+ `;
280
+ }
281
+
282
+ export function renderDocumentsView(state) {
283
+ if (!state.connections.active) {
284
+ return {
285
+ main: renderMissingDatabase(),
286
+ panel: '',
287
+ };
288
+ }
289
+
290
+ const documents = state.documents;
291
+
292
+ return {
293
+ main: `<div class="documents-view">
294
+ ${renderDocumentsSidebar(documents)}
295
+ ${renderDocumentDetail(documents)}
296
+ </div>
297
+ </div>`,
298
+ panel: '',
299
+ };
300
+ }