@aiot-nexus/plugin-sdk 2.0.2 → 2.1.1

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 (32) hide show
  1. package/README.md +18 -8
  2. package/dev-host/host.html +13 -12
  3. package/dev-host/main.js +134 -9
  4. package/dev-host/plugin-host.js +1 -427
  5. package/dist/.generated/plugin-framework-version.d.ts +1 -1
  6. package/dist/.generated/plugin-framework-version.js +1 -1
  7. package/dist/.generated/plugin-manifest.schema.json +1 -1
  8. package/dist/internal/src-electron/main/core/runtime-result.js +42 -0
  9. package/dist/internal/src-electron/main/plugins/plugin-electron-facade-broker.js +890 -0
  10. package/dist/internal/src-electron/plugin-host-bootstrap.js +417 -0
  11. package/dist/internal/src-electron/shared/contracts.js +2 -0
  12. package/dist/internal/src-electron/shared/plugin-electron-facade-client.js +181 -0
  13. package/dist/internal/src-electron/shared/plugin-electron-facade-descriptors.js +70 -0
  14. package/dist/internal/src-electron/shared/plugin-electron-facade.js +243 -0
  15. package/dist/internal/src-electron/shared/plugin-rpc.js +245 -0
  16. package/dist/src/electron-facade.d.ts +4 -0
  17. package/dist/src/electron-facade.js +56 -0
  18. package/dist/src/index.d.ts +2 -1
  19. package/dist/src/index.js +2 -1
  20. package/dist/src/nexus-dev.js +5 -4
  21. package/dist/src/scaffold.js +2 -2
  22. package/dist/src/types.d.ts +86 -2
  23. package/dist/src/web-dev.js +2 -2
  24. package/package.json +7 -2
  25. package/templates/minimal/README.md.tmpl +84 -3
  26. package/templates/minimal/src/main.ts.tmpl +2 -1
  27. package/templates/sqlite/README.md.tmpl +30 -2
  28. package/templates/sqlite/src/better-sqlite3.d.ts +12 -1
  29. package/templates/sqlite/src/main.ts.tmpl +1034 -124
  30. package/templates/sqlite/src/renderer/app.ts.tmpl +691 -180
  31. package/templates/sqlite/src/renderer/index.html +40 -22
  32. package/templates/sqlite/src/renderer/plugin.css +302 -90
@@ -1,66 +1,152 @@
1
1
  import { createPluginFrameClient } from "@aiot-nexus/plugin-sdk/browser";
2
2
 
3
+ type DatabaseSource = "default" | "external";
4
+ type SqliteAffinity = "integer" | "real" | "text" | "blob" | "numeric";
5
+ type LocatorKind = "primaryKey" | "rowid";
6
+ type EditorMode = "insert" | "update";
7
+ type SupportedLanguage = "en-US" | "zh-CN";
8
+ type ThemeMode = "light" | "dark" | "system";
9
+ type MessageParams = Record<string, string | number>;
10
+
3
11
  interface DatabaseInfo {
4
12
  databaseName: string;
5
13
  databasePath: string;
6
- tableName: string;
7
- recordCount: number;
14
+ source: DatabaseSource;
8
15
  databaseReady: boolean;
16
+ notice?: string;
9
17
  }
10
18
 
11
- interface SqliteRecord {
12
- id: number;
13
- title: string;
14
- note: string;
15
- createdAt: string;
19
+ interface TableSummary {
20
+ name: string;
21
+ columnCount: number;
22
+ withoutRowid: boolean;
23
+ strict: boolean;
16
24
  }
17
25
 
18
- type SupportedLanguage = "en-US" | "zh-CN";
19
- type ThemeMode = "light" | "dark" | "system";
20
- type MessageParams = Record<string, string | number>;
26
+ interface ColumnInfo {
27
+ name: string;
28
+ declaredType: string;
29
+ affinity: SqliteAffinity;
30
+ nullable: boolean;
31
+ defaultSql: string | null;
32
+ primaryKeyOrder: number;
33
+ generated: boolean;
34
+ hidden: boolean;
35
+ insertable: boolean;
36
+ requiredOnInsert: boolean;
37
+ autoIntegerPrimaryKey: boolean;
38
+ }
39
+
40
+ type WireValue =
41
+ | { kind: "null" }
42
+ | { kind: "integer"; value: string }
43
+ | { kind: "real"; value: number }
44
+ | { kind: "text"; value: string; length: number; truncated: boolean }
45
+ | { kind: "blob"; hex: string; byteLength: number; truncated: boolean };
46
+
47
+ interface RowLocator {
48
+ kind: LocatorKind;
49
+ values: Array<{ column: string; value: WireValue }>;
50
+ }
51
+
52
+ interface TableRow {
53
+ cells: Array<{ column: string; value: WireValue }>;
54
+ locator?: RowLocator;
55
+ }
56
+
57
+ interface TableData {
58
+ table: TableSummary;
59
+ columns: ColumnInfo[];
60
+ rows: TableRow[];
61
+ totalRows: string;
62
+ offset: number;
63
+ limit: number;
64
+ hasPrevious: boolean;
65
+ hasNext: boolean;
66
+ writable: boolean;
67
+ locatorKind?: LocatorKind;
68
+ }
69
+
70
+ interface EditorDraftValue {
71
+ mode: string;
72
+ value: string;
73
+ }
21
74
 
22
75
  const englishMessages = {
23
- "document.title": "Plugin SQLite",
24
- "heading.records": "SQLite Records",
76
+ "document.title": "SQLite Browser",
77
+ "heading.browser": "SQLite Browser",
78
+ "toolbar.databaseActions": "Database actions",
25
79
  "summary.aria": "Database summary",
26
80
  "summary.database": "Database",
81
+ "summary.source": "Source",
27
82
  "summary.table": "Table",
83
+ "summary.columns": "Columns",
28
84
  "summary.rows": "Rows",
29
85
  "summary.status": "Status",
30
- "form.insertRow": "Insert Row",
31
- "form.editRow": "Edit Row",
32
- "field.title": "Title",
33
- "field.note": "Note",
34
- "section.recentRows": "Recent Rows",
35
- "table.id": "ID",
36
- "table.title": "Title",
37
- "table.note": "Note",
38
- "table.created": "Created",
39
- "table.actions": "Actions",
86
+ "source.default": "Sample",
87
+ "source.external": "External",
88
+ "section.rows": "Table Rows",
89
+ "editor.insert": "Insert Row",
90
+ "editor.update": "Edit Row",
91
+ "editor.help": "Choose how each SQLite column should be written.",
92
+ "editor.current": "Current: {value}",
93
+ "column.untyped": "UNTYPED",
94
+ "column.primaryKey": "PK {order}",
95
+ "column.notNull": "NOT NULL",
96
+ "column.default": "DEFAULT {value}",
97
+ "column.generated": "GENERATED",
98
+ "column.required": "Required",
99
+ "mode.default": "Use default",
100
+ "mode.unchanged": "Do not change",
101
+ "mode.null": "NULL",
102
+ "mode.value": "Value",
103
+ "action.openDatabase": "Open Database",
104
+ "action.useDefault": "Use Sample",
40
105
  "action.refresh": "Refresh",
106
+ "action.addRow": "Add Row",
41
107
  "action.insert": "Insert",
42
108
  "action.save": "Save",
43
109
  "action.cancel": "Cancel",
44
- "action.confirm": "Confirm",
110
+ "action.close": "Close",
45
111
  "action.edit": "Edit",
46
112
  "action.delete": "Delete",
113
+ "action.confirm": "Confirm",
114
+ "action.previous": "Previous",
115
+ "action.next": "Next",
116
+ "table.actions": "Actions",
117
+ "pager.aria": "Pagination",
118
+ "pager.pageSize": "Rows per page",
119
+ "pager.range": "{start}-{end} / {total}",
47
120
  "status.connecting": "Connecting",
48
121
  "status.waiting": "Waiting",
49
122
  "status.idle": "Idle",
50
123
  "status.loading": "Loading",
51
124
  "status.ready": "Ready",
52
125
  "status.unavailable": "Unavailable",
53
- "status.updating": "Updating",
54
- "status.inserting": "Inserting",
55
- "status.deleting": "Deleting",
56
- "status.updatedRow": "Updated row #{id}.",
57
- "status.insertedRow": "Inserted row #{id}.",
58
- "status.deletedRow": "Deleted row #{id}.",
59
- "empty.noRows": "No rows yet.",
60
- "validation.titleRequired": "Title is required.",
126
+ "status.opening": "Opening database",
127
+ "status.switching": "Switching database",
128
+ "status.inserting": "Inserting row",
129
+ "status.updating": "Updating row",
130
+ "status.deleting": "Deleting row",
131
+ "status.databaseOpened": "Opened {name}.",
132
+ "status.defaultOpened": "Using the sample database.",
133
+ "status.selectionCanceled": "Database selection canceled.",
134
+ "status.insertedRow": "Inserted row into {table}.",
135
+ "status.updatedRow": "Updated row in {table}.",
136
+ "status.deletedRow": "Deleted row from {table}.",
137
+ "status.writeSucceededRefreshFailed": "{operation} Rows could not be refreshed: {message}",
138
+ "empty.noTables": "No user tables were found in this database.",
139
+ "empty.noRows": "No rows in this table.",
140
+ "empty.noWritableColumns": "This table has no writable columns.",
141
+ "empty.noLocator": "This row has no stable primary-key or rowid locator.",
142
+ "value.null": "NULL",
143
+ "value.blob": "BLOB · {bytes} bytes · {preview}",
144
+ "value.truncated": "{value}… ({length} chars)",
145
+ "placeholder.value": "Enter value",
146
+ "placeholder.hex": "Hexadecimal bytes, for example 89504e47",
61
147
  "error.initializationFailed": "Initialization failed: {message}",
62
148
  "error.refreshFailed": "Refresh failed: {message}",
63
- "error.deleteFailed": "Delete failed: {message}"
149
+ "error.operationFailed": "Operation failed: {message}"
64
150
  } as const;
65
151
 
66
152
  type MessageKey = keyof typeof englishMessages;
@@ -68,47 +154,80 @@ type MessageKey = keyof typeof englishMessages;
68
154
  const messages: Record<SupportedLanguage, Record<MessageKey, string>> = {
69
155
  "en-US": englishMessages,
70
156
  "zh-CN": {
71
- "document.title": "插件 SQLite",
72
- "heading.records": "SQLite 记录",
157
+ "document.title": "SQLite 数据库浏览器",
158
+ "heading.browser": "SQLite 数据库浏览器",
159
+ "toolbar.databaseActions": "数据库操作",
73
160
  "summary.aria": "数据库摘要",
74
161
  "summary.database": "数据库",
162
+ "summary.source": "来源",
75
163
  "summary.table": "数据表",
164
+ "summary.columns": "数据列",
76
165
  "summary.rows": "记录数",
77
166
  "summary.status": "状态",
78
- "form.insertRow": "新增记录",
79
- "form.editRow": "编辑记录",
80
- "field.title": "标题",
81
- "field.note": "备注",
82
- "section.recentRows": "最近记录",
83
- "table.id": "ID",
84
- "table.title": "标题",
85
- "table.note": "备注",
86
- "table.created": "创建时间",
87
- "table.actions": "操作",
167
+ "source.default": "示例库",
168
+ "source.external": "外部文件",
169
+ "section.rows": "表数据",
170
+ "editor.insert": "新增记录",
171
+ "editor.update": "编辑记录",
172
+ "editor.help": "为每个 SQLite 数据列选择写入方式。",
173
+ "editor.current": "当前值:{value}",
174
+ "column.untyped": "未声明类型",
175
+ "column.primaryKey": "主键 {order}",
176
+ "column.notNull": "非空",
177
+ "column.default": "默认 {value}",
178
+ "column.generated": "生成列",
179
+ "column.required": "必填",
180
+ "mode.default": "使用默认值",
181
+ "mode.unchanged": "不修改",
182
+ "mode.null": "NULL",
183
+ "mode.value": "输入值",
184
+ "action.openDatabase": "打开数据库",
185
+ "action.useDefault": "使用示例库",
88
186
  "action.refresh": "刷新",
187
+ "action.addRow": "新增记录",
89
188
  "action.insert": "新增",
90
189
  "action.save": "保存",
91
190
  "action.cancel": "取消",
92
- "action.confirm": "确认",
191
+ "action.close": "关闭",
93
192
  "action.edit": "编辑",
94
193
  "action.delete": "删除",
194
+ "action.confirm": "确认",
195
+ "action.previous": "上一页",
196
+ "action.next": "下一页",
197
+ "table.actions": "操作",
198
+ "pager.aria": "分页",
199
+ "pager.pageSize": "每页记录",
200
+ "pager.range": "{start}-{end} / {total}",
95
201
  "status.connecting": "正在连接",
96
202
  "status.waiting": "等待中",
97
203
  "status.idle": "空闲",
98
204
  "status.loading": "正在加载",
99
205
  "status.ready": "已就绪",
100
206
  "status.unavailable": "不可用",
101
- "status.updating": "正在更新",
102
- "status.inserting": "正在新增",
103
- "status.deleting": "正在删除",
104
- "status.updatedRow": "已更新第 #{id} 条记录。",
105
- "status.insertedRow": "已新增第 #{id} 条记录。",
106
- "status.deletedRow": "已删除第 #{id} 条记录。",
107
- "empty.noRows": "暂无记录。",
108
- "validation.titleRequired": "标题不能为空。",
207
+ "status.opening": "正在打开数据库",
208
+ "status.switching": "正在切换数据库",
209
+ "status.inserting": "正在新增记录",
210
+ "status.updating": "正在更新记录",
211
+ "status.deleting": "正在删除记录",
212
+ "status.databaseOpened": "已打开 {name}。",
213
+ "status.defaultOpened": "正在使用示例数据库。",
214
+ "status.selectionCanceled": "已取消数据库选择。",
215
+ "status.insertedRow": "已向 {table} 新增记录。",
216
+ "status.updatedRow": "已更新 {table} 中的记录。",
217
+ "status.deletedRow": "已删除 {table} 中的记录。",
218
+ "status.writeSucceededRefreshFailed": "{operation} 但表格刷新失败:{message}",
219
+ "empty.noTables": "数据库中没有可浏览的用户表。",
220
+ "empty.noRows": "当前数据表没有记录。",
221
+ "empty.noWritableColumns": "当前数据表没有可写数据列。",
222
+ "empty.noLocator": "当前记录没有稳定的主键或 rowid 定位信息。",
223
+ "value.null": "NULL",
224
+ "value.blob": "BLOB · {bytes} 字节 · {preview}",
225
+ "value.truncated": "{value}…(共 {length} 个字符)",
226
+ "placeholder.value": "请输入值",
227
+ "placeholder.hex": "十六进制字节,例如 89504e47",
109
228
  "error.initializationFailed": "初始化失败:{message}",
110
229
  "error.refreshFailed": "刷新失败:{message}",
111
- "error.deleteFailed": "删除失败:{message}"
230
+ "error.operationFailed": "操作失败:{message}"
112
231
  }
113
232
  };
114
233
 
@@ -120,45 +239,77 @@ const client = createPluginFrameClient();
120
239
  const elements = {
121
240
  databasePath: requireElement<HTMLElement>("databasePath"),
122
241
  databaseName: requireElement<HTMLElement>("databaseName"),
123
- tableName: requireElement<HTMLElement>("tableName"),
242
+ databaseSource: requireElement<HTMLElement>("databaseSource"),
243
+ tableSelect: requireElement<HTMLSelectElement>("tableSelect"),
244
+ columnCount: requireElement<HTMLElement>("columnCount"),
124
245
  recordCount: requireElement<HTMLElement>("recordCount"),
125
246
  loadingState: requireElement<HTMLElement>("loadingState"),
247
+ openDatabaseButton: requireElement<HTMLButtonElement>("openDatabaseButton"),
248
+ useDefaultDatabaseButton: requireElement<HTMLButtonElement>("useDefaultDatabaseButton"),
126
249
  refreshButton: requireElement<HTMLButtonElement>("refreshButton"),
127
- entryFormTitle: requireElement<HTMLElement>("entryFormTitle"),
128
- titleInput: requireElement<HTMLInputElement>("titleInput"),
129
- noteInput: requireElement<HTMLTextAreaElement>("noteInput"),
130
- insertButton: requireElement<HTMLButtonElement>("insertButton"),
131
- cancelEditButton: requireElement<HTMLButtonElement>("cancelEditButton"),
250
+ addRowButton: requireElement<HTMLButtonElement>("addRowButton"),
251
+ editorPanel: requireElement<HTMLElement>("editorPanel"),
252
+ editorTitle: requireElement<HTMLElement>("editorTitle"),
253
+ editorFields: requireElement<HTMLElement>("editorFields"),
254
+ saveRowButton: requireElement<HTMLButtonElement>("saveRowButton"),
255
+ cancelEditorButton: requireElement<HTMLButtonElement>("cancelEditorButton"),
256
+ closeEditorButton: requireElement<HTMLButtonElement>("closeEditorButton"),
132
257
  formError: requireElement<HTMLElement>("formError"),
133
258
  emptyState: requireElement<HTMLElement>("emptyState"),
259
+ recordsHead: requireElement<HTMLTableRowElement>("recordsHead"),
134
260
  recordsBody: requireElement<HTMLTableSectionElement>("recordsBody"),
135
- statusLog: requireElement<HTMLElement>("statusLog")
261
+ statusLog: requireElement<HTMLElement>("statusLog"),
262
+ pageSizeSelect: requireElement<HTMLSelectElement>("pageSizeSelect"),
263
+ pageLabel: requireElement<HTMLElement>("pageLabel"),
264
+ previousPageButton: requireElement<HTMLButtonElement>("previousPageButton"),
265
+ nextPageButton: requireElement<HTMLButtonElement>("nextPageButton")
136
266
  };
137
267
  const systemDarkMode = window.matchMedia("(prefers-color-scheme: dark)");
138
268
 
139
269
  let language: SupportedLanguage = "en-US";
140
270
  let themeMode: ThemeMode = "system";
141
- let records: SqliteRecord[] = [];
142
- let editingRecord: SqliteRecord | undefined;
143
- let pendingDeleteId: number | undefined;
144
- let databasePath = "";
271
+ let databaseInfo: DatabaseInfo | undefined;
272
+ let tables: TableSummary[] = [];
273
+ let tableData: TableData | undefined;
274
+ let selectedTable = "";
275
+ let offset = 0;
276
+ let pageSize = 50;
277
+ let editorMode: EditorMode | undefined;
278
+ let editingRow: TableRow | undefined;
279
+ let pendingDeleteLocator = "";
145
280
  let busy = false;
146
281
  let busyLabelKey: MessageKey = "status.waiting";
147
282
  let statusState: LocalizedState = { kind: "message", key: "status.idle" };
148
283
  let formErrorState: LocalizedState | undefined;
149
284
 
150
- elements.refreshButton.addEventListener("click", () => void refreshAll());
151
- elements.insertButton.addEventListener("click", () => void saveRecord());
152
- elements.cancelEditButton.addEventListener("click", resetEntryForm);
153
- elements.titleInput.addEventListener("keydown", (event) => {
154
- if (event.key === "Enter") {
155
- event.preventDefault();
156
- void saveRecord();
157
- }
285
+ elements.openDatabaseButton.addEventListener("click", () => void openDatabase());
286
+ elements.useDefaultDatabaseButton.addEventListener("click", () => void useDefaultDatabase());
287
+ elements.refreshButton.addEventListener("click", () => void refreshDatabase(true));
288
+ elements.addRowButton.addEventListener("click", () => openEditor("insert"));
289
+ elements.saveRowButton.addEventListener("click", () => void saveRow());
290
+ elements.cancelEditorButton.addEventListener("click", closeEditor);
291
+ elements.closeEditorButton.addEventListener("click", closeEditor);
292
+ elements.tableSelect.addEventListener("change", () => {
293
+ selectedTable = elements.tableSelect.value;
294
+ offset = 0;
295
+ closeEditor();
296
+ void loadSelectedTable();
158
297
  });
159
- const stopContextUpdates = client.onContextChanged((context) => {
160
- applyContext(context);
298
+ elements.pageSizeSelect.addEventListener("change", () => {
299
+ pageSize = Number(elements.pageSizeSelect.value);
300
+ offset = 0;
301
+ void loadSelectedTable();
302
+ });
303
+ elements.previousPageButton.addEventListener("click", () => {
304
+ offset = Math.max(0, offset - pageSize);
305
+ void loadSelectedTable();
161
306
  });
307
+ elements.nextPageButton.addEventListener("click", () => {
308
+ offset += pageSize;
309
+ void loadSelectedTable();
310
+ });
311
+
312
+ const stopContextUpdates = client.onContextChanged(applyContext);
162
313
  systemDarkMode.addEventListener("change", handleSystemThemeChange);
163
314
  window.addEventListener("beforeunload", () => {
164
315
  stopContextUpdates();
@@ -168,7 +319,7 @@ window.addEventListener("beforeunload", () => {
168
319
 
169
320
  void client.waitForContext().then((context) => {
170
321
  applyContext(context);
171
- return refreshAll();
322
+ return refreshDatabase(false);
172
323
  }).catch((error: unknown) => {
173
324
  applyLanguage(language);
174
325
  applyTheme(themeMode);
@@ -176,121 +327,476 @@ void client.waitForContext().then((context) => {
176
327
  writeStatusMessage("error.initializationFailed", { message: errorMessage(error) });
177
328
  });
178
329
 
179
- async function refreshAll(): Promise<void> {
330
+ async function refreshDatabase(preserveSelection: boolean): Promise<boolean> {
180
331
  setBusy(true, "status.loading");
181
332
  try {
182
- const [info, list] = await Promise.all([
183
- invoke<DatabaseInfo>("sqlite.getInfo"),
184
- invoke<{ records: SqliteRecord[] }>("sqlite.listRecords")
333
+ const [info, tableList] = await Promise.all([
334
+ invoke<DatabaseInfo>("sqlite.getDatabaseInfo"),
335
+ invoke<{ tables: TableSummary[] }>("sqlite.listTables")
185
336
  ]);
186
- elements.databaseName.textContent = info.databaseName;
187
- databasePath = info.databasePath;
188
- renderDatabasePath();
189
- elements.tableName.textContent = info.tableName;
190
- elements.recordCount.textContent = String(info.recordCount);
191
- records = list.records;
192
- renderRecords();
193
- writeStatusMessage(info.databaseReady ? "status.ready" : "status.unavailable");
337
+ databaseInfo = info;
338
+ tables = tableList.tables;
339
+ renderDatabaseInfo();
340
+ const previous = preserveSelection ? selectedTable : "";
341
+ selectedTable = tables.some((table) => table.name === previous)
342
+ ? previous
343
+ : tables.some((table) => table.name === "plugin_records")
344
+ ? "plugin_records"
345
+ : tables[0]?.name || "";
346
+ offset = preserveSelection && selectedTable === previous ? offset : 0;
347
+ renderTableSelector();
348
+ await readSelectedTable();
349
+ if (info.notice) {
350
+ writeStatusLiteral(info.notice);
351
+ } else {
352
+ writeStatusMessage("status.ready");
353
+ }
354
+ return true;
194
355
  } catch (error: unknown) {
356
+ tableData = undefined;
357
+ renderTableData();
195
358
  writeStatusMessage("error.refreshFailed", { message: errorMessage(error) });
359
+ return false;
196
360
  } finally {
197
361
  setBusy(false, "status.idle");
198
362
  }
199
363
  }
200
364
 
201
- async function saveRecord(): Promise<void> {
202
- clearFormError();
203
- const title = elements.titleInput.value.trim();
204
- const note = elements.noteInput.value.trim();
205
- if (!title) {
206
- showFormErrorMessage("validation.titleRequired");
207
- return;
365
+ async function openDatabase(): Promise<void> {
366
+ setBusy(true, "status.opening");
367
+ try {
368
+ const result = await invoke<{ canceled: boolean; database: DatabaseInfo }>("sqlite.chooseDatabase");
369
+ if (result.canceled) {
370
+ writeStatusMessage("status.selectionCanceled");
371
+ return;
372
+ }
373
+ closeEditor();
374
+ selectedTable = "";
375
+ offset = 0;
376
+ if (await refreshDatabase(false)) {
377
+ writeStatusMessage("status.databaseOpened", { name: result.database.databaseName });
378
+ }
379
+ } catch (error: unknown) {
380
+ writeStatusMessage("error.operationFailed", { message: errorMessage(error) });
381
+ } finally {
382
+ setBusy(false, "status.idle");
208
383
  }
209
- setBusy(true, editingRecord ? "status.updating" : "status.inserting");
384
+ }
385
+
386
+ async function useDefaultDatabase(): Promise<void> {
387
+ setBusy(true, "status.switching");
210
388
  try {
211
- if (editingRecord) {
212
- await invoke<{ record: SqliteRecord }>("sqlite.updateRecord", { id: editingRecord.id, title, note });
213
- writeStatusMessage("status.updatedRow", { id: editingRecord.id });
214
- } else {
215
- const inserted = await invoke<{ record: SqliteRecord }>("sqlite.insertRecord", { title, note });
216
- writeStatusMessage("status.insertedRow", { id: inserted.record.id });
389
+ await invoke<{ database: DatabaseInfo }>("sqlite.useDefaultDatabase");
390
+ closeEditor();
391
+ selectedTable = "plugin_records";
392
+ offset = 0;
393
+ if (await refreshDatabase(true)) {
394
+ writeStatusMessage("status.defaultOpened");
217
395
  }
218
- resetEntryForm();
219
- await refreshAll();
220
396
  } catch (error: unknown) {
221
- showFormErrorLiteral(errorMessage(error));
397
+ writeStatusMessage("error.operationFailed", { message: errorMessage(error) });
222
398
  } finally {
223
399
  setBusy(false, "status.idle");
224
400
  }
225
401
  }
226
402
 
227
- function startEdit(record: SqliteRecord): void {
228
- editingRecord = record;
229
- pendingDeleteId = undefined;
230
- elements.titleInput.value = record.title;
231
- elements.noteInput.value = record.note;
232
- elements.cancelEditButton.hidden = false;
233
- elements.titleInput.focus();
234
- renderEntryForm();
235
- renderRecords();
403
+ async function loadSelectedTable(): Promise<void> {
404
+ setBusy(true, "status.loading");
405
+ try {
406
+ await readSelectedTable();
407
+ writeStatusMessage("status.ready");
408
+ } catch (error: unknown) {
409
+ tableData = undefined;
410
+ renderTableData();
411
+ writeStatusMessage("error.refreshFailed", { message: errorMessage(error) });
412
+ } finally {
413
+ setBusy(false, "status.idle");
414
+ }
236
415
  }
237
416
 
238
- function requestDelete(record: SqliteRecord): void {
239
- pendingDeleteId = record.id;
240
- renderRecords();
417
+ async function readSelectedTable(): Promise<void> {
418
+ if (!selectedTable) {
419
+ tableData = undefined;
420
+ renderTableData();
421
+ return;
422
+ }
423
+ tableData = await invoke<TableData>("sqlite.readTable", {
424
+ table: selectedTable,
425
+ offset,
426
+ limit: pageSize
427
+ });
428
+ offset = tableData.offset;
429
+ renderTableData();
241
430
  }
242
431
 
243
- async function deleteRecord(record: SqliteRecord): Promise<void> {
432
+ function openEditor(mode: EditorMode, row?: TableRow): void {
433
+ if (!tableData || !tableData.writable) {
434
+ writeStatusMessage("empty.noWritableColumns");
435
+ return;
436
+ }
437
+ if (mode === "update" && !row?.locator) {
438
+ writeStatusMessage("empty.noLocator");
439
+ return;
440
+ }
441
+ editorMode = mode;
442
+ editingRow = row;
443
+ pendingDeleteLocator = "";
444
+ clearFormError();
445
+ renderEditor();
446
+ elements.editorPanel.hidden = false;
447
+ elements.editorPanel.scrollIntoView({ block: "nearest" });
448
+ client.notifyResize();
449
+ }
450
+
451
+ function closeEditor(): void {
452
+ editorMode = undefined;
453
+ editingRow = undefined;
454
+ clearFormError();
455
+ elements.editorPanel.hidden = true;
456
+ elements.editorFields.replaceChildren();
457
+ client.notifyResize();
458
+ }
459
+
460
+ async function saveRow(): Promise<void> {
461
+ if (!tableData || !editorMode) {
462
+ return;
463
+ }
464
+ clearFormError();
465
+ const values = readEditorValues();
466
+ setBusy(true, editorMode === "insert" ? "status.inserting" : "status.updating");
467
+ try {
468
+ let successKey: MessageKey;
469
+ try {
470
+ if (editorMode === "insert") {
471
+ await invoke("sqlite.insertRow", { table: selectedTable, values });
472
+ successKey = "status.insertedRow";
473
+ } else {
474
+ if (!editingRow?.locator) {
475
+ throw new Error(text("empty.noLocator"));
476
+ }
477
+ await invoke("sqlite.updateRow", { table: selectedTable, locator: editingRow.locator, values });
478
+ successKey = "status.updatedRow";
479
+ }
480
+ } catch (error: unknown) {
481
+ showFormErrorLiteral(errorMessage(error));
482
+ return;
483
+ }
484
+ closeEditor();
485
+ await refreshAfterMutation(successKey, { table: selectedTable });
486
+ } finally {
487
+ setBusy(false, "status.idle");
488
+ }
489
+ }
490
+
491
+ async function deleteRow(row: TableRow): Promise<void> {
492
+ if (!row.locator) {
493
+ writeStatusMessage("empty.noLocator");
494
+ return;
495
+ }
244
496
  setBusy(true, "status.deleting");
245
497
  try {
246
- await invoke<{ deletedId: number }>("sqlite.deleteRecord", { id: record.id });
247
- if (editingRecord?.id === record.id) {
248
- resetEntryForm();
498
+ try {
499
+ await invoke("sqlite.deleteRow", { table: selectedTable, locator: row.locator });
500
+ } catch (error: unknown) {
501
+ writeStatusMessage("error.operationFailed", { message: errorMessage(error) });
502
+ return;
249
503
  }
250
- pendingDeleteId = undefined;
251
- writeStatusMessage("status.deletedRow", { id: record.id });
252
- await refreshAll();
253
- } catch (error: unknown) {
254
- writeStatusMessage("error.deleteFailed", { message: errorMessage(error) });
504
+ pendingDeleteLocator = "";
505
+ if (editingRow && locatorKey(editingRow.locator) === locatorKey(row.locator)) {
506
+ closeEditor();
507
+ }
508
+ await refreshAfterMutation("status.deletedRow", { table: selectedTable });
255
509
  } finally {
256
510
  setBusy(false, "status.idle");
257
511
  }
258
512
  }
259
513
 
260
- function renderRecords(): void {
261
- const rows = records.map((record) => {
262
- const row = document.createElement("tr");
263
- appendCell(row, String(record.id));
264
- appendCell(row, record.title);
265
- appendCell(row, record.note);
266
- appendCell(row, new Date(record.createdAt).toLocaleString(language));
267
- const actions = document.createElement("td");
268
- actions.className = "record-actions";
269
- if (pendingDeleteId === record.id) {
270
- actions.append(
271
- actionButton(text("action.confirm"), "button-danger", () => void deleteRecord(record)),
272
- actionButton(text("action.cancel"), "button-secondary", () => {
273
- pendingDeleteId = undefined;
274
- renderRecords();
275
- })
276
- );
277
- } else {
278
- actions.append(
279
- actionButton(text("action.edit"), "button-secondary", () => startEdit(record)),
280
- actionButton(text("action.delete"), "button-secondary", () => requestDelete(record))
281
- );
514
+ async function refreshAfterMutation(successKey: MessageKey, params: MessageParams): Promise<void> {
515
+ const operation = text(successKey, params);
516
+ try {
517
+ await readSelectedTable();
518
+ writeStatusMessage(successKey, params);
519
+ } catch (error: unknown) {
520
+ writeStatusMessage("status.writeSucceededRefreshFailed", {
521
+ operation,
522
+ message: errorMessage(error)
523
+ });
524
+ }
525
+ }
526
+
527
+ function renderDatabaseInfo(): void {
528
+ elements.databaseName.textContent = databaseInfo?.databaseName || "-";
529
+ elements.databaseSource.textContent = databaseInfo ? text(databaseInfo.source === "default" ? "source.default" : "source.external") : "-";
530
+ elements.databasePath.textContent = databaseInfo?.databasePath || text("status.connecting");
531
+ elements.databasePath.title = databaseInfo?.databasePath || "";
532
+ }
533
+
534
+ function renderTableSelector(): void {
535
+ const options = tables.map((table) => {
536
+ const option = document.createElement("option");
537
+ option.value = table.name;
538
+ option.textContent = table.name;
539
+ option.selected = table.name === selectedTable;
540
+ return option;
541
+ });
542
+ elements.tableSelect.replaceChildren(...options);
543
+ elements.tableSelect.disabled = busy || tables.length === 0;
544
+ }
545
+
546
+ function renderTableData(): void {
547
+ const data = tableData;
548
+ elements.columnCount.textContent = data ? String(data.columns.filter((column) => !column.hidden).length) : "0";
549
+ elements.recordCount.textContent = data?.totalRows || "0";
550
+ renderTableHeader();
551
+ renderRows();
552
+ renderPager();
553
+ elements.addRowButton.disabled = busy || !data?.writable;
554
+ if (!selectedTable) {
555
+ elements.emptyState.textContent = text("empty.noTables");
556
+ elements.emptyState.hidden = false;
557
+ } else {
558
+ elements.emptyState.textContent = text("empty.noRows");
559
+ elements.emptyState.hidden = Boolean(data && data.rows.length > 0);
560
+ }
561
+ client.notifyResize();
562
+ }
563
+
564
+ function renderTableHeader(): void {
565
+ if (!tableData) {
566
+ elements.recordsHead.replaceChildren();
567
+ return;
568
+ }
569
+ const headers = tableData.columns.filter((column) => !column.hidden).map((column) => {
570
+ const header = document.createElement("th");
571
+ const title = document.createElement("div");
572
+ title.className = "column-title";
573
+ const name = document.createElement("strong");
574
+ name.textContent = column.name;
575
+ title.append(name, badge(column.declaredType || text("column.untyped")));
576
+ if (column.primaryKeyOrder > 0) {
577
+ title.append(badge(text("column.primaryKey", { order: column.primaryKeyOrder })));
578
+ }
579
+ if (!column.nullable) {
580
+ title.append(badge(text("column.notNull")));
581
+ }
582
+ if (column.defaultSql !== null) {
583
+ title.append(badge(text("column.default", { value: column.defaultSql })));
282
584
  }
283
- row.append(actions);
284
- return row;
585
+ if (column.generated) {
586
+ title.append(badge(text("column.generated")));
587
+ }
588
+ header.append(title);
589
+ return header;
590
+ });
591
+ if (tableData.locatorKind) {
592
+ const actions = document.createElement("th");
593
+ actions.className = "actions-column";
594
+ actions.textContent = text("table.actions");
595
+ headers.push(actions);
596
+ }
597
+ elements.recordsHead.replaceChildren(...headers);
598
+ }
599
+
600
+ function renderRows(): void {
601
+ if (!tableData) {
602
+ elements.recordsBody.replaceChildren();
603
+ return;
604
+ }
605
+ const rows = tableData.rows.map((row) => {
606
+ const tableRow = document.createElement("tr");
607
+ for (const cell of row.cells) {
608
+ tableRow.append(renderCell(cell.value));
609
+ }
610
+ if (tableData?.locatorKind) {
611
+ tableRow.append(renderRowActions(row));
612
+ }
613
+ return tableRow;
285
614
  });
286
615
  elements.recordsBody.replaceChildren(...rows);
287
- elements.emptyState.hidden = rows.length > 0;
288
616
  }
289
617
 
290
- function appendCell(row: HTMLTableRowElement, value: string): void {
618
+ function renderCell(value: WireValue): HTMLTableCellElement {
291
619
  const cell = document.createElement("td");
292
- cell.textContent = value;
293
- row.append(cell);
620
+ const display = displayValue(value);
621
+ cell.textContent = display;
622
+ cell.title = display;
623
+ if (value.kind === "null") {
624
+ cell.className = "cell-null";
625
+ } else if (value.kind === "blob") {
626
+ cell.className = "cell-blob";
627
+ } else if (value.kind === "text" && value.truncated) {
628
+ cell.className = "cell-truncated";
629
+ }
630
+ return cell;
631
+ }
632
+
633
+ function renderRowActions(row: TableRow): HTMLTableCellElement {
634
+ const cell = document.createElement("td");
635
+ cell.className = "row-actions-cell";
636
+ const actions = document.createElement("div");
637
+ actions.className = "row-actions";
638
+ if (!row.locator) {
639
+ actions.textContent = text("empty.noLocator");
640
+ cell.append(actions);
641
+ return cell;
642
+ }
643
+ const key = locatorKey(row.locator);
644
+ if (pendingDeleteLocator === key) {
645
+ actions.append(
646
+ actionButton(text("action.confirm"), "button-danger compact-button", () => void deleteRow(row)),
647
+ actionButton(text("action.cancel"), "button-secondary compact-button", () => {
648
+ pendingDeleteLocator = "";
649
+ renderRows();
650
+ })
651
+ );
652
+ } else {
653
+ actions.append(
654
+ actionButton(text("action.edit"), "button-secondary compact-button", () => openEditor("update", row)),
655
+ actionButton(text("action.delete"), "button-secondary compact-button", () => {
656
+ pendingDeleteLocator = key;
657
+ renderRows();
658
+ })
659
+ );
660
+ }
661
+ cell.append(actions);
662
+ return cell;
663
+ }
664
+
665
+ function renderPager(): void {
666
+ const data = tableData;
667
+ const total = data?.totalRows || "0";
668
+ const start = data && data.rows.length > 0 ? data.offset + 1 : 0;
669
+ const end = data ? data.offset + data.rows.length : 0;
670
+ elements.pageLabel.textContent = text("pager.range", { start, end, total });
671
+ elements.previousPageButton.disabled = busy || !data?.hasPrevious;
672
+ elements.nextPageButton.disabled = busy || !data?.hasNext;
673
+ elements.pageSizeSelect.disabled = busy || !data;
674
+ }
675
+
676
+ function renderEditor(draft: Map<string, EditorDraftValue> = readEditorDraft()): void {
677
+ if (!editorMode || !tableData) {
678
+ return;
679
+ }
680
+ elements.editorTitle.textContent = text(editorMode === "insert" ? "editor.insert" : "editor.update");
681
+ elements.saveRowButton.textContent = text(editorMode === "insert" ? "action.insert" : "action.save");
682
+ const currentValues = new Map((editingRow?.cells || []).map((cell) => [cell.column, cell.value]));
683
+ const fields = tableData.columns.filter((column) => column.insertable).map((column) => {
684
+ const field = document.createElement("div");
685
+ field.className = "editor-field";
686
+ field.dataset.column = column.name;
687
+ const heading = document.createElement("div");
688
+ heading.className = "field-heading";
689
+ const name = document.createElement("strong");
690
+ name.textContent = column.name;
691
+ heading.append(name, badge(column.declaredType || text("column.untyped")));
692
+ if (column.requiredOnInsert && editorMode === "insert") {
693
+ heading.append(badge(text("column.required")));
694
+ }
695
+ const modeSelect = document.createElement("select");
696
+ modeSelect.className = "field-mode";
697
+ modeSelect.setAttribute("aria-label", column.name + " mode");
698
+ const availableModes = editorMode === "insert"
699
+ ? (["default", ...(column.nullable ? ["null"] : []), "value"] as string[])
700
+ : (["unchanged", ...(column.nullable ? ["null"] : []), "value"] as string[]);
701
+ const defaultMode = editorMode === "insert" && column.requiredOnInsert ? "value" : availableModes[0];
702
+ const savedDraft = draft.get(column.name);
703
+ const selectedMode = savedDraft && availableModes.includes(savedDraft.mode) ? savedDraft.mode : defaultMode;
704
+ for (const mode of availableModes) {
705
+ const option = document.createElement("option");
706
+ option.value = mode;
707
+ option.textContent = text(("mode." + mode) as MessageKey);
708
+ option.selected = mode === selectedMode;
709
+ modeSelect.append(option);
710
+ }
711
+ const input = column.affinity === "text" || column.affinity === "blob"
712
+ ? document.createElement("textarea")
713
+ : document.createElement("input");
714
+ input.className = "field-input";
715
+ input.setAttribute("aria-label", column.name + " value");
716
+ input.dataset.affinity = column.affinity;
717
+ input.placeholder = text(column.affinity === "blob" ? "placeholder.hex" : "placeholder.value");
718
+ const currentValue = currentValues.get(column.name);
719
+ input.value = savedDraft?.value ?? editableValue(currentValue);
720
+ input.disabled = selectedMode !== "value" || busy;
721
+ modeSelect.disabled = busy;
722
+ modeSelect.addEventListener("change", () => {
723
+ input.disabled = modeSelect.value !== "value" || busy;
724
+ if (!input.disabled) {
725
+ input.focus();
726
+ }
727
+ });
728
+ field.append(heading, modeSelect, input);
729
+ if (editorMode === "update" && currentValue) {
730
+ const current = document.createElement("div");
731
+ current.className = "field-current";
732
+ current.textContent = text("editor.current", { value: displayValue(currentValue) });
733
+ current.title = displayValue(currentValue);
734
+ field.append(current);
735
+ }
736
+ return field;
737
+ });
738
+ elements.editorFields.replaceChildren(...fields);
739
+ renderBusyState();
740
+ }
741
+
742
+ function readEditorValues(): Record<string, { mode: string; value?: string }> {
743
+ const values: Record<string, { mode: string; value?: string }> = Object.create(null) as Record<string, { mode: string; value?: string }>;
744
+ elements.editorFields.querySelectorAll<HTMLElement>(".editor-field").forEach((field) => {
745
+ const column = field.dataset.column;
746
+ const mode = field.querySelector<HTMLSelectElement>(".field-mode");
747
+ const input = field.querySelector<HTMLInputElement | HTMLTextAreaElement>(".field-input");
748
+ if (column && mode && input) {
749
+ values[column] = mode.value === "value" ? { mode: mode.value, value: input.value } : { mode: mode.value };
750
+ }
751
+ });
752
+ return values;
753
+ }
754
+
755
+ function readEditorDraft(): Map<string, EditorDraftValue> {
756
+ const result = new Map<string, EditorDraftValue>();
757
+ elements.editorFields.querySelectorAll<HTMLElement>(".editor-field").forEach((field) => {
758
+ const column = field.dataset.column;
759
+ const mode = field.querySelector<HTMLSelectElement>(".field-mode");
760
+ const input = field.querySelector<HTMLInputElement | HTMLTextAreaElement>(".field-input");
761
+ if (column && mode && input) {
762
+ result.set(column, { mode: mode.value, value: input.value });
763
+ }
764
+ });
765
+ return result;
766
+ }
767
+
768
+ function editableValue(value: WireValue | undefined): string {
769
+ if (!value || value.kind === "null") {
770
+ return "";
771
+ }
772
+ if (value.kind === "integer" || value.kind === "real") {
773
+ return String(value.value);
774
+ }
775
+ if (value.kind === "text") {
776
+ return value.truncated ? "" : value.value;
777
+ }
778
+ return value.truncated ? "" : value.hex;
779
+ }
780
+
781
+ function displayValue(value: WireValue): string {
782
+ if (value.kind === "null") {
783
+ return text("value.null");
784
+ }
785
+ if (value.kind === "integer" || value.kind === "real") {
786
+ return String(value.value);
787
+ }
788
+ if (value.kind === "blob") {
789
+ return text("value.blob", { bytes: value.byteLength, preview: value.hex + (value.truncated ? "…" : "") });
790
+ }
791
+ return value.truncated ? text("value.truncated", { value: value.value, length: value.length }) : value.value;
792
+ }
793
+
794
+ function badge(value: string): HTMLElement {
795
+ const element = document.createElement("span");
796
+ element.className = "column-badge";
797
+ element.textContent = value;
798
+ element.title = value;
799
+ return element;
294
800
  }
295
801
 
296
802
  function actionButton(label: string, className: string, action: () => void): HTMLButtonElement {
@@ -303,18 +809,8 @@ function actionButton(label: string, className: string, action: () => void): HTM
303
809
  return button;
304
810
  }
305
811
 
306
- function resetEntryForm(): void {
307
- editingRecord = undefined;
308
- elements.titleInput.value = "";
309
- elements.noteInput.value = "";
310
- elements.cancelEditButton.hidden = true;
311
- clearFormError();
312
- renderEntryForm();
313
- }
314
-
315
- function renderEntryForm(): void {
316
- elements.entryFormTitle.textContent = text(editingRecord ? "form.editRow" : "form.insertRow");
317
- elements.insertButton.textContent = text(editingRecord ? "action.save" : "action.insert");
812
+ function locatorKey(locator: RowLocator | undefined): string {
813
+ return locator ? JSON.stringify(locator) : "";
318
814
  }
319
815
 
320
816
  function setBusy(nextBusy: boolean, labelKey: MessageKey): void {
@@ -325,18 +821,29 @@ function setBusy(nextBusy: boolean, labelKey: MessageKey): void {
325
821
 
326
822
  function renderBusyState(): void {
327
823
  elements.loadingState.textContent = text(busyLabelKey);
824
+ elements.openDatabaseButton.disabled = busy;
825
+ elements.useDefaultDatabaseButton.disabled = busy || databaseInfo?.source === "default";
328
826
  elements.refreshButton.disabled = busy;
329
- elements.insertButton.disabled = busy;
827
+ elements.tableSelect.disabled = busy || tables.length === 0;
828
+ elements.addRowButton.disabled = busy || !tableData?.writable;
829
+ elements.saveRowButton.disabled = busy;
830
+ elements.cancelEditorButton.disabled = busy;
831
+ elements.closeEditorButton.disabled = busy;
832
+ elements.pageSizeSelect.disabled = busy || !tableData;
833
+ elements.previousPageButton.disabled = busy || !tableData?.hasPrevious;
834
+ elements.nextPageButton.disabled = busy || !tableData?.hasNext;
835
+ elements.editorFields.querySelectorAll<HTMLSelectElement>("select").forEach((select) => {
836
+ select.disabled = busy;
837
+ });
838
+ elements.editorFields.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>("input, textarea").forEach((input) => {
839
+ const mode = input.closest(".editor-field")?.querySelector<HTMLSelectElement>(".field-mode")?.value;
840
+ input.disabled = busy || mode !== "value";
841
+ });
330
842
  elements.recordsBody.querySelectorAll<HTMLButtonElement>("button").forEach((button) => {
331
843
  button.disabled = busy;
332
844
  });
333
845
  }
334
846
 
335
- function showFormErrorMessage(key: MessageKey, params?: MessageParams): void {
336
- formErrorState = { kind: "message", key, params };
337
- renderFormError();
338
- }
339
-
340
847
  function showFormErrorLiteral(value: string): void {
341
848
  formErrorState = { kind: "literal", value };
342
849
  renderFormError();
@@ -362,16 +869,17 @@ function writeStatusMessage(key: MessageKey, params?: MessageParams): void {
362
869
  renderStatus();
363
870
  }
364
871
 
365
- function renderStatus(): void {
366
- elements.statusLog.textContent = renderState(statusState);
872
+ function writeStatusLiteral(value: string): void {
873
+ statusState = { kind: "literal", value };
874
+ renderStatus();
367
875
  }
368
876
 
369
- function renderDatabasePath(): void {
370
- elements.databasePath.textContent = databasePath || text("status.connecting");
371
- elements.databasePath.title = databasePath;
877
+ function renderStatus(): void {
878
+ elements.statusLog.textContent = renderState(statusState);
372
879
  }
373
880
 
374
881
  function applyLanguage(nextLanguage: SupportedLanguage): void {
882
+ const editorDraft = readEditorDraft();
375
883
  language = nextLanguage;
376
884
  document.documentElement.lang = language;
377
885
  document.querySelectorAll<HTMLElement>("[data-i18n]").forEach((element) => {
@@ -386,9 +894,12 @@ function applyLanguage(nextLanguage: SupportedLanguage): void {
386
894
  element.setAttribute("aria-label", text(key));
387
895
  }
388
896
  });
389
- renderDatabasePath();
390
- renderEntryForm();
391
- renderRecords();
897
+ renderDatabaseInfo();
898
+ renderTableSelector();
899
+ renderTableData();
900
+ if (editorMode) {
901
+ renderEditor(editorDraft);
902
+ }
392
903
  renderBusyState();
393
904
  renderStatus();
394
905
  renderFormError();