@aiot-nexus/plugin-sdk 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +18 -8
  2. package/dev-host/main.js +134 -7
  3. package/dev-host/plugin-host.js +1 -427
  4. package/dist/.generated/plugin-framework-version.d.ts +1 -1
  5. package/dist/.generated/plugin-framework-version.js +1 -1
  6. package/dist/.generated/plugin-manifest.schema.json +1 -1
  7. package/dist/internal/src-electron/main/core/runtime-result.js +42 -0
  8. package/dist/internal/src-electron/main/plugins/plugin-electron-facade-broker.js +890 -0
  9. package/dist/internal/src-electron/plugin-host-bootstrap.js +417 -0
  10. package/dist/internal/src-electron/shared/contracts.js +2 -0
  11. package/dist/internal/src-electron/shared/plugin-electron-facade-client.js +181 -0
  12. package/dist/internal/src-electron/shared/plugin-electron-facade-descriptors.js +70 -0
  13. package/dist/internal/src-electron/shared/plugin-electron-facade.js +243 -0
  14. package/dist/internal/src-electron/shared/plugin-rpc.js +245 -0
  15. package/dist/src/electron-facade.d.ts +4 -0
  16. package/dist/src/electron-facade.js +56 -0
  17. package/dist/src/index.d.ts +2 -1
  18. package/dist/src/index.js +2 -1
  19. package/dist/src/nexus-dev.js +5 -4
  20. package/dist/src/scaffold.js +2 -2
  21. package/dist/src/types.d.ts +86 -2
  22. package/dist/src/web-dev.js +2 -2
  23. package/package.json +7 -2
  24. package/templates/minimal/README.md.tmpl +35 -2
  25. package/templates/minimal/src/main.ts.tmpl +2 -1
  26. package/templates/sqlite/README.md.tmpl +30 -2
  27. package/templates/sqlite/src/better-sqlite3.d.ts +12 -1
  28. package/templates/sqlite/src/main.ts.tmpl +939 -124
  29. package/templates/sqlite/src/renderer/app.ts.tmpl +688 -181
  30. package/templates/sqlite/src/renderer/index.html +40 -22
  31. 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,472 @@ 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<void> {
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
+ }
194
354
  } catch (error: unknown) {
355
+ tableData = undefined;
356
+ renderTableData();
195
357
  writeStatusMessage("error.refreshFailed", { message: errorMessage(error) });
196
358
  } finally {
197
359
  setBusy(false, "status.idle");
198
360
  }
199
361
  }
200
362
 
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;
208
- }
209
- setBusy(true, editingRecord ? "status.updating" : "status.inserting");
363
+ async function openDatabase(): Promise<void> {
364
+ setBusy(true, "status.opening");
210
365
  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 });
366
+ const result = await invoke<{ canceled: boolean; database: DatabaseInfo }>("sqlite.chooseDatabase");
367
+ if (result.canceled) {
368
+ writeStatusMessage("status.selectionCanceled");
369
+ return;
217
370
  }
218
- resetEntryForm();
219
- await refreshAll();
371
+ closeEditor();
372
+ selectedTable = "";
373
+ offset = 0;
374
+ await refreshDatabase(false);
375
+ writeStatusMessage("status.databaseOpened", { name: result.database.databaseName });
376
+ } catch (error: unknown) {
377
+ writeStatusMessage("error.operationFailed", { message: errorMessage(error) });
378
+ } finally {
379
+ setBusy(false, "status.idle");
380
+ }
381
+ }
382
+
383
+ async function useDefaultDatabase(): Promise<void> {
384
+ setBusy(true, "status.switching");
385
+ try {
386
+ await invoke<{ database: DatabaseInfo }>("sqlite.useDefaultDatabase");
387
+ closeEditor();
388
+ selectedTable = "plugin_records";
389
+ offset = 0;
390
+ await refreshDatabase(true);
391
+ writeStatusMessage("status.defaultOpened");
220
392
  } catch (error: unknown) {
221
- showFormErrorLiteral(errorMessage(error));
393
+ writeStatusMessage("error.operationFailed", { message: errorMessage(error) });
222
394
  } finally {
223
395
  setBusy(false, "status.idle");
224
396
  }
225
397
  }
226
398
 
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();
399
+ async function loadSelectedTable(): Promise<void> {
400
+ setBusy(true, "status.loading");
401
+ try {
402
+ await readSelectedTable();
403
+ writeStatusMessage("status.ready");
404
+ } catch (error: unknown) {
405
+ tableData = undefined;
406
+ renderTableData();
407
+ writeStatusMessage("error.refreshFailed", { message: errorMessage(error) });
408
+ } finally {
409
+ setBusy(false, "status.idle");
410
+ }
236
411
  }
237
412
 
238
- function requestDelete(record: SqliteRecord): void {
239
- pendingDeleteId = record.id;
240
- renderRecords();
413
+ async function readSelectedTable(): Promise<void> {
414
+ if (!selectedTable) {
415
+ tableData = undefined;
416
+ renderTableData();
417
+ return;
418
+ }
419
+ tableData = await invoke<TableData>("sqlite.readTable", {
420
+ table: selectedTable,
421
+ offset,
422
+ limit: pageSize
423
+ });
424
+ offset = tableData.offset;
425
+ renderTableData();
241
426
  }
242
427
 
243
- async function deleteRecord(record: SqliteRecord): Promise<void> {
428
+ function openEditor(mode: EditorMode, row?: TableRow): void {
429
+ if (!tableData || !tableData.writable) {
430
+ writeStatusMessage("empty.noWritableColumns");
431
+ return;
432
+ }
433
+ if (mode === "update" && !row?.locator) {
434
+ writeStatusMessage("empty.noLocator");
435
+ return;
436
+ }
437
+ editorMode = mode;
438
+ editingRow = row;
439
+ pendingDeleteLocator = "";
440
+ clearFormError();
441
+ renderEditor();
442
+ elements.editorPanel.hidden = false;
443
+ elements.editorPanel.scrollIntoView({ block: "nearest" });
444
+ client.notifyResize();
445
+ }
446
+
447
+ function closeEditor(): void {
448
+ editorMode = undefined;
449
+ editingRow = undefined;
450
+ clearFormError();
451
+ elements.editorPanel.hidden = true;
452
+ elements.editorFields.replaceChildren();
453
+ client.notifyResize();
454
+ }
455
+
456
+ async function saveRow(): Promise<void> {
457
+ if (!tableData || !editorMode) {
458
+ return;
459
+ }
460
+ clearFormError();
461
+ const values = readEditorValues();
462
+ setBusy(true, editorMode === "insert" ? "status.inserting" : "status.updating");
463
+ try {
464
+ let successKey: MessageKey;
465
+ try {
466
+ if (editorMode === "insert") {
467
+ await invoke("sqlite.insertRow", { table: selectedTable, values });
468
+ successKey = "status.insertedRow";
469
+ } else {
470
+ if (!editingRow?.locator) {
471
+ throw new Error(text("empty.noLocator"));
472
+ }
473
+ await invoke("sqlite.updateRow", { table: selectedTable, locator: editingRow.locator, values });
474
+ successKey = "status.updatedRow";
475
+ }
476
+ } catch (error: unknown) {
477
+ showFormErrorLiteral(errorMessage(error));
478
+ return;
479
+ }
480
+ closeEditor();
481
+ await refreshAfterMutation(successKey, { table: selectedTable });
482
+ } finally {
483
+ setBusy(false, "status.idle");
484
+ }
485
+ }
486
+
487
+ async function deleteRow(row: TableRow): Promise<void> {
488
+ if (!row.locator) {
489
+ writeStatusMessage("empty.noLocator");
490
+ return;
491
+ }
244
492
  setBusy(true, "status.deleting");
245
493
  try {
246
- await invoke<{ deletedId: number }>("sqlite.deleteRecord", { id: record.id });
247
- if (editingRecord?.id === record.id) {
248
- resetEntryForm();
494
+ try {
495
+ await invoke("sqlite.deleteRow", { table: selectedTable, locator: row.locator });
496
+ } catch (error: unknown) {
497
+ writeStatusMessage("error.operationFailed", { message: errorMessage(error) });
498
+ return;
249
499
  }
250
- pendingDeleteId = undefined;
251
- writeStatusMessage("status.deletedRow", { id: record.id });
252
- await refreshAll();
253
- } catch (error: unknown) {
254
- writeStatusMessage("error.deleteFailed", { message: errorMessage(error) });
500
+ pendingDeleteLocator = "";
501
+ if (editingRow && locatorKey(editingRow.locator) === locatorKey(row.locator)) {
502
+ closeEditor();
503
+ }
504
+ await refreshAfterMutation("status.deletedRow", { table: selectedTable });
255
505
  } finally {
256
506
  setBusy(false, "status.idle");
257
507
  }
258
508
  }
259
509
 
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
- );
510
+ async function refreshAfterMutation(successKey: MessageKey, params: MessageParams): Promise<void> {
511
+ const operation = text(successKey, params);
512
+ try {
513
+ await readSelectedTable();
514
+ writeStatusMessage(successKey, params);
515
+ } catch (error: unknown) {
516
+ writeStatusMessage("status.writeSucceededRefreshFailed", {
517
+ operation,
518
+ message: errorMessage(error)
519
+ });
520
+ }
521
+ }
522
+
523
+ function renderDatabaseInfo(): void {
524
+ elements.databaseName.textContent = databaseInfo?.databaseName || "-";
525
+ elements.databaseSource.textContent = databaseInfo ? text(databaseInfo.source === "default" ? "source.default" : "source.external") : "-";
526
+ elements.databasePath.textContent = databaseInfo?.databasePath || text("status.connecting");
527
+ elements.databasePath.title = databaseInfo?.databasePath || "";
528
+ }
529
+
530
+ function renderTableSelector(): void {
531
+ const options = tables.map((table) => {
532
+ const option = document.createElement("option");
533
+ option.value = table.name;
534
+ option.textContent = table.name;
535
+ option.selected = table.name === selectedTable;
536
+ return option;
537
+ });
538
+ elements.tableSelect.replaceChildren(...options);
539
+ elements.tableSelect.disabled = busy || tables.length === 0;
540
+ }
541
+
542
+ function renderTableData(): void {
543
+ const data = tableData;
544
+ elements.columnCount.textContent = data ? String(data.columns.filter((column) => !column.hidden).length) : "0";
545
+ elements.recordCount.textContent = data?.totalRows || "0";
546
+ renderTableHeader();
547
+ renderRows();
548
+ renderPager();
549
+ elements.addRowButton.disabled = busy || !data?.writable;
550
+ if (!selectedTable) {
551
+ elements.emptyState.textContent = text("empty.noTables");
552
+ elements.emptyState.hidden = false;
553
+ } else {
554
+ elements.emptyState.textContent = text("empty.noRows");
555
+ elements.emptyState.hidden = Boolean(data && data.rows.length > 0);
556
+ }
557
+ client.notifyResize();
558
+ }
559
+
560
+ function renderTableHeader(): void {
561
+ if (!tableData) {
562
+ elements.recordsHead.replaceChildren();
563
+ return;
564
+ }
565
+ const headers = tableData.columns.filter((column) => !column.hidden).map((column) => {
566
+ const header = document.createElement("th");
567
+ const title = document.createElement("div");
568
+ title.className = "column-title";
569
+ const name = document.createElement("strong");
570
+ name.textContent = column.name;
571
+ title.append(name, badge(column.declaredType || text("column.untyped")));
572
+ if (column.primaryKeyOrder > 0) {
573
+ title.append(badge(text("column.primaryKey", { order: column.primaryKeyOrder })));
574
+ }
575
+ if (!column.nullable) {
576
+ title.append(badge(text("column.notNull")));
577
+ }
578
+ if (column.defaultSql !== null) {
579
+ title.append(badge(text("column.default", { value: column.defaultSql })));
282
580
  }
283
- row.append(actions);
284
- return row;
581
+ if (column.generated) {
582
+ title.append(badge(text("column.generated")));
583
+ }
584
+ header.append(title);
585
+ return header;
586
+ });
587
+ if (tableData.locatorKind) {
588
+ const actions = document.createElement("th");
589
+ actions.className = "actions-column";
590
+ actions.textContent = text("table.actions");
591
+ headers.push(actions);
592
+ }
593
+ elements.recordsHead.replaceChildren(...headers);
594
+ }
595
+
596
+ function renderRows(): void {
597
+ if (!tableData) {
598
+ elements.recordsBody.replaceChildren();
599
+ return;
600
+ }
601
+ const rows = tableData.rows.map((row) => {
602
+ const tableRow = document.createElement("tr");
603
+ for (const cell of row.cells) {
604
+ tableRow.append(renderCell(cell.value));
605
+ }
606
+ if (tableData?.locatorKind) {
607
+ tableRow.append(renderRowActions(row));
608
+ }
609
+ return tableRow;
285
610
  });
286
611
  elements.recordsBody.replaceChildren(...rows);
287
- elements.emptyState.hidden = rows.length > 0;
288
612
  }
289
613
 
290
- function appendCell(row: HTMLTableRowElement, value: string): void {
614
+ function renderCell(value: WireValue): HTMLTableCellElement {
291
615
  const cell = document.createElement("td");
292
- cell.textContent = value;
293
- row.append(cell);
616
+ const display = displayValue(value);
617
+ cell.textContent = display;
618
+ cell.title = display;
619
+ if (value.kind === "null") {
620
+ cell.className = "cell-null";
621
+ } else if (value.kind === "blob") {
622
+ cell.className = "cell-blob";
623
+ } else if (value.kind === "text" && value.truncated) {
624
+ cell.className = "cell-truncated";
625
+ }
626
+ return cell;
627
+ }
628
+
629
+ function renderRowActions(row: TableRow): HTMLTableCellElement {
630
+ const cell = document.createElement("td");
631
+ cell.className = "row-actions-cell";
632
+ const actions = document.createElement("div");
633
+ actions.className = "row-actions";
634
+ if (!row.locator) {
635
+ actions.textContent = text("empty.noLocator");
636
+ cell.append(actions);
637
+ return cell;
638
+ }
639
+ const key = locatorKey(row.locator);
640
+ if (pendingDeleteLocator === key) {
641
+ actions.append(
642
+ actionButton(text("action.confirm"), "button-danger compact-button", () => void deleteRow(row)),
643
+ actionButton(text("action.cancel"), "button-secondary compact-button", () => {
644
+ pendingDeleteLocator = "";
645
+ renderRows();
646
+ })
647
+ );
648
+ } else {
649
+ actions.append(
650
+ actionButton(text("action.edit"), "button-secondary compact-button", () => openEditor("update", row)),
651
+ actionButton(text("action.delete"), "button-secondary compact-button", () => {
652
+ pendingDeleteLocator = key;
653
+ renderRows();
654
+ })
655
+ );
656
+ }
657
+ cell.append(actions);
658
+ return cell;
659
+ }
660
+
661
+ function renderPager(): void {
662
+ const data = tableData;
663
+ const total = data?.totalRows || "0";
664
+ const start = data && data.rows.length > 0 ? data.offset + 1 : 0;
665
+ const end = data ? data.offset + data.rows.length : 0;
666
+ elements.pageLabel.textContent = text("pager.range", { start, end, total });
667
+ elements.previousPageButton.disabled = busy || !data?.hasPrevious;
668
+ elements.nextPageButton.disabled = busy || !data?.hasNext;
669
+ elements.pageSizeSelect.disabled = busy || !data;
670
+ }
671
+
672
+ function renderEditor(draft: Map<string, EditorDraftValue> = readEditorDraft()): void {
673
+ if (!editorMode || !tableData) {
674
+ return;
675
+ }
676
+ elements.editorTitle.textContent = text(editorMode === "insert" ? "editor.insert" : "editor.update");
677
+ elements.saveRowButton.textContent = text(editorMode === "insert" ? "action.insert" : "action.save");
678
+ const currentValues = new Map((editingRow?.cells || []).map((cell) => [cell.column, cell.value]));
679
+ const fields = tableData.columns.filter((column) => column.insertable).map((column) => {
680
+ const field = document.createElement("div");
681
+ field.className = "editor-field";
682
+ field.dataset.column = column.name;
683
+ const heading = document.createElement("div");
684
+ heading.className = "field-heading";
685
+ const name = document.createElement("strong");
686
+ name.textContent = column.name;
687
+ heading.append(name, badge(column.declaredType || text("column.untyped")));
688
+ if (column.requiredOnInsert && editorMode === "insert") {
689
+ heading.append(badge(text("column.required")));
690
+ }
691
+ const modeSelect = document.createElement("select");
692
+ modeSelect.className = "field-mode";
693
+ modeSelect.setAttribute("aria-label", column.name + " mode");
694
+ const availableModes = editorMode === "insert"
695
+ ? (["default", ...(column.nullable ? ["null"] : []), "value"] as string[])
696
+ : (["unchanged", ...(column.nullable ? ["null"] : []), "value"] as string[]);
697
+ const defaultMode = editorMode === "insert" && column.requiredOnInsert ? "value" : availableModes[0];
698
+ const savedDraft = draft.get(column.name);
699
+ const selectedMode = savedDraft && availableModes.includes(savedDraft.mode) ? savedDraft.mode : defaultMode;
700
+ for (const mode of availableModes) {
701
+ const option = document.createElement("option");
702
+ option.value = mode;
703
+ option.textContent = text(("mode." + mode) as MessageKey);
704
+ option.selected = mode === selectedMode;
705
+ modeSelect.append(option);
706
+ }
707
+ const input = column.affinity === "text" || column.affinity === "blob"
708
+ ? document.createElement("textarea")
709
+ : document.createElement("input");
710
+ input.className = "field-input";
711
+ input.setAttribute("aria-label", column.name + " value");
712
+ input.dataset.affinity = column.affinity;
713
+ input.placeholder = text(column.affinity === "blob" ? "placeholder.hex" : "placeholder.value");
714
+ const currentValue = currentValues.get(column.name);
715
+ input.value = savedDraft?.value ?? editableValue(currentValue);
716
+ input.disabled = selectedMode !== "value" || busy;
717
+ modeSelect.disabled = busy;
718
+ modeSelect.addEventListener("change", () => {
719
+ input.disabled = modeSelect.value !== "value" || busy;
720
+ if (!input.disabled) {
721
+ input.focus();
722
+ }
723
+ });
724
+ field.append(heading, modeSelect, input);
725
+ if (editorMode === "update" && currentValue) {
726
+ const current = document.createElement("div");
727
+ current.className = "field-current";
728
+ current.textContent = text("editor.current", { value: displayValue(currentValue) });
729
+ current.title = displayValue(currentValue);
730
+ field.append(current);
731
+ }
732
+ return field;
733
+ });
734
+ elements.editorFields.replaceChildren(...fields);
735
+ renderBusyState();
736
+ }
737
+
738
+ function readEditorValues(): Record<string, { mode: string; value?: string }> {
739
+ const values: Record<string, { mode: string; value?: string }> = Object.create(null) as Record<string, { mode: string; value?: string }>;
740
+ elements.editorFields.querySelectorAll<HTMLElement>(".editor-field").forEach((field) => {
741
+ const column = field.dataset.column;
742
+ const mode = field.querySelector<HTMLSelectElement>(".field-mode");
743
+ const input = field.querySelector<HTMLInputElement | HTMLTextAreaElement>(".field-input");
744
+ if (column && mode && input) {
745
+ values[column] = mode.value === "value" ? { mode: mode.value, value: input.value } : { mode: mode.value };
746
+ }
747
+ });
748
+ return values;
749
+ }
750
+
751
+ function readEditorDraft(): Map<string, EditorDraftValue> {
752
+ const result = new Map<string, EditorDraftValue>();
753
+ elements.editorFields.querySelectorAll<HTMLElement>(".editor-field").forEach((field) => {
754
+ const column = field.dataset.column;
755
+ const mode = field.querySelector<HTMLSelectElement>(".field-mode");
756
+ const input = field.querySelector<HTMLInputElement | HTMLTextAreaElement>(".field-input");
757
+ if (column && mode && input) {
758
+ result.set(column, { mode: mode.value, value: input.value });
759
+ }
760
+ });
761
+ return result;
762
+ }
763
+
764
+ function editableValue(value: WireValue | undefined): string {
765
+ if (!value || value.kind === "null") {
766
+ return "";
767
+ }
768
+ if (value.kind === "integer" || value.kind === "real") {
769
+ return String(value.value);
770
+ }
771
+ if (value.kind === "text") {
772
+ return value.truncated ? "" : value.value;
773
+ }
774
+ return value.truncated ? "" : value.hex;
775
+ }
776
+
777
+ function displayValue(value: WireValue): string {
778
+ if (value.kind === "null") {
779
+ return text("value.null");
780
+ }
781
+ if (value.kind === "integer" || value.kind === "real") {
782
+ return String(value.value);
783
+ }
784
+ if (value.kind === "blob") {
785
+ return text("value.blob", { bytes: value.byteLength, preview: value.hex + (value.truncated ? "…" : "") });
786
+ }
787
+ return value.truncated ? text("value.truncated", { value: value.value, length: value.length }) : value.value;
788
+ }
789
+
790
+ function badge(value: string): HTMLElement {
791
+ const element = document.createElement("span");
792
+ element.className = "column-badge";
793
+ element.textContent = value;
794
+ element.title = value;
795
+ return element;
294
796
  }
295
797
 
296
798
  function actionButton(label: string, className: string, action: () => void): HTMLButtonElement {
@@ -303,18 +805,8 @@ function actionButton(label: string, className: string, action: () => void): HTM
303
805
  return button;
304
806
  }
305
807
 
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");
808
+ function locatorKey(locator: RowLocator | undefined): string {
809
+ return locator ? JSON.stringify(locator) : "";
318
810
  }
319
811
 
320
812
  function setBusy(nextBusy: boolean, labelKey: MessageKey): void {
@@ -325,18 +817,29 @@ function setBusy(nextBusy: boolean, labelKey: MessageKey): void {
325
817
 
326
818
  function renderBusyState(): void {
327
819
  elements.loadingState.textContent = text(busyLabelKey);
820
+ elements.openDatabaseButton.disabled = busy;
821
+ elements.useDefaultDatabaseButton.disabled = busy || databaseInfo?.source === "default";
328
822
  elements.refreshButton.disabled = busy;
329
- elements.insertButton.disabled = busy;
823
+ elements.tableSelect.disabled = busy || tables.length === 0;
824
+ elements.addRowButton.disabled = busy || !tableData?.writable;
825
+ elements.saveRowButton.disabled = busy;
826
+ elements.cancelEditorButton.disabled = busy;
827
+ elements.closeEditorButton.disabled = busy;
828
+ elements.pageSizeSelect.disabled = busy || !tableData;
829
+ elements.previousPageButton.disabled = busy || !tableData?.hasPrevious;
830
+ elements.nextPageButton.disabled = busy || !tableData?.hasNext;
831
+ elements.editorFields.querySelectorAll<HTMLSelectElement>("select").forEach((select) => {
832
+ select.disabled = busy;
833
+ });
834
+ elements.editorFields.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>("input, textarea").forEach((input) => {
835
+ const mode = input.closest(".editor-field")?.querySelector<HTMLSelectElement>(".field-mode")?.value;
836
+ input.disabled = busy || mode !== "value";
837
+ });
330
838
  elements.recordsBody.querySelectorAll<HTMLButtonElement>("button").forEach((button) => {
331
839
  button.disabled = busy;
332
840
  });
333
841
  }
334
842
 
335
- function showFormErrorMessage(key: MessageKey, params?: MessageParams): void {
336
- formErrorState = { kind: "message", key, params };
337
- renderFormError();
338
- }
339
-
340
843
  function showFormErrorLiteral(value: string): void {
341
844
  formErrorState = { kind: "literal", value };
342
845
  renderFormError();
@@ -362,16 +865,17 @@ function writeStatusMessage(key: MessageKey, params?: MessageParams): void {
362
865
  renderStatus();
363
866
  }
364
867
 
365
- function renderStatus(): void {
366
- elements.statusLog.textContent = renderState(statusState);
868
+ function writeStatusLiteral(value: string): void {
869
+ statusState = { kind: "literal", value };
870
+ renderStatus();
367
871
  }
368
872
 
369
- function renderDatabasePath(): void {
370
- elements.databasePath.textContent = databasePath || text("status.connecting");
371
- elements.databasePath.title = databasePath;
873
+ function renderStatus(): void {
874
+ elements.statusLog.textContent = renderState(statusState);
372
875
  }
373
876
 
374
877
  function applyLanguage(nextLanguage: SupportedLanguage): void {
878
+ const editorDraft = readEditorDraft();
375
879
  language = nextLanguage;
376
880
  document.documentElement.lang = language;
377
881
  document.querySelectorAll<HTMLElement>("[data-i18n]").forEach((element) => {
@@ -386,9 +890,12 @@ function applyLanguage(nextLanguage: SupportedLanguage): void {
386
890
  element.setAttribute("aria-label", text(key));
387
891
  }
388
892
  });
389
- renderDatabasePath();
390
- renderEntryForm();
391
- renderRecords();
893
+ renderDatabaseInfo();
894
+ renderTableSelector();
895
+ renderTableData();
896
+ if (editorMode) {
897
+ renderEditor(editorDraft);
898
+ }
392
899
  renderBusyState();
393
900
  renderStatus();
394
901
  renderFormError();