sqlseed-web 0.2.4__py3-none-any.whl

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 (87) hide show
  1. sqlseed_web/AGENTS.md +106 -0
  2. sqlseed_web/__init__.py +24 -0
  3. sqlseed_web/__main__.py +8 -0
  4. sqlseed_web/_application.py +205 -0
  5. sqlseed_web/ai_settings.py +290 -0
  6. sqlseed_web/api.py +1102 -0
  7. sqlseed_web/app.py +26 -0
  8. sqlseed_web/managed_worker.py +184 -0
  9. sqlseed_web/operation_errors.py +42 -0
  10. sqlseed_web/plugin_environment.py +231 -0
  11. sqlseed_web/plugin_management.py +322 -0
  12. sqlseed_web/plugin_process.py +131 -0
  13. sqlseed_web/runtime_lifecycle.py +130 -0
  14. sqlseed_web/runtime_session.py +97 -0
  15. sqlseed_web/settings_environment.py +368 -0
  16. sqlseed_web/sqlite_target.py +86 -0
  17. sqlseed_web/state.py +348 -0
  18. sqlseed_web/static/AGENTS.md +180 -0
  19. sqlseed_web/static/ai.css +57 -0
  20. sqlseed_web/static/configs.css +50 -0
  21. sqlseed_web/static/date-picker.css +230 -0
  22. sqlseed_web/static/disclosure.css +149 -0
  23. sqlseed_web/static/graph-clarity.css +103 -0
  24. sqlseed_web/static/index.html +29 -0
  25. sqlseed_web/static/js/api.js +228 -0
  26. sqlseed_web/static/js/app.js +97 -0
  27. sqlseed_web/static/js/dropdown.js +432 -0
  28. sqlseed_web/static/js/filepicker.js +203 -0
  29. sqlseed_web/static/js/genform.js +1066 -0
  30. sqlseed_web/static/js/labels.js +195 -0
  31. sqlseed_web/static/js/pages/browse.js +209 -0
  32. sqlseed_web/static/js/pages/configs.js +424 -0
  33. sqlseed_web/static/js/pages/connect.js +332 -0
  34. sqlseed_web/static/js/pages/heal.js +395 -0
  35. sqlseed_web/static/js/pages/meta.js +110 -0
  36. sqlseed_web/static/js/pages/runs.js +293 -0
  37. sqlseed_web/static/js/pages/settings.js +942 -0
  38. sqlseed_web/static/js/pages/wizard.js +751 -0
  39. sqlseed_web/static/js/pages/workbench.js +3123 -0
  40. sqlseed_web/static/js/tree.js +126 -0
  41. sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
  42. sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
  43. sqlseed_web/static/js/workbench/ai-stream.js +116 -0
  44. sqlseed_web/static/js/workbench/ai.js +888 -0
  45. sqlseed_web/static/js/workbench/connection.js +508 -0
  46. sqlseed_web/static/js/workbench/date-picker.js +445 -0
  47. sqlseed_web/static/js/workbench/dependency-view.js +119 -0
  48. sqlseed_web/static/js/workbench/editor.js +1236 -0
  49. sqlseed_web/static/js/workbench/focus.js +11 -0
  50. sqlseed_web/static/js/workbench/graph-layout.js +332 -0
  51. sqlseed_web/static/js/workbench/graph.js +970 -0
  52. sqlseed_web/static/js/workbench/guidance.js +29 -0
  53. sqlseed_web/static/js/workbench/model.js +124 -0
  54. sqlseed_web/static/js/workbench/plugin-management.js +512 -0
  55. sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
  56. sqlseed_web/static/js/workbench/preview.js +572 -0
  57. sqlseed_web/static/js/workbench/provider-guide.js +33 -0
  58. sqlseed_web/static/js/workbench/recovery.js +28 -0
  59. sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
  60. sqlseed_web/static/js/workbench/session.js +174 -0
  61. sqlseed_web/static/js/workbench/table-data.js +186 -0
  62. sqlseed_web/static/js/workbench/ui.js +262 -0
  63. sqlseed_web/static/navigation.css +92 -0
  64. sqlseed_web/static/preview.css +29 -0
  65. sqlseed_web/static/runs.css +53 -0
  66. sqlseed_web/static/scrollbars.css +42 -0
  67. sqlseed_web/static/settings.css +108 -0
  68. sqlseed_web/static/style.css +3382 -0
  69. sqlseed_web/static/table-data.css +27 -0
  70. sqlseed_web/static/workbench.css +509 -0
  71. sqlseed_web/supervised_plugins.py +173 -0
  72. sqlseed_web/supervisor.py +238 -0
  73. sqlseed_web/workbench.py +381 -0
  74. sqlseed_web/workbench_ai.py +887 -0
  75. sqlseed_web/workbench_ai_relations.py +285 -0
  76. sqlseed_web/workbench_ai_stream.py +172 -0
  77. sqlseed_web/workbench_data.py +163 -0
  78. sqlseed_web/workbench_execution.py +199 -0
  79. sqlseed_web/workbench_runtime.py +1218 -0
  80. sqlseed_web/workbench_schema.py +277 -0
  81. sqlseed_web/workbench_store.py +458 -0
  82. sqlseed_web/worker_control.py +192 -0
  83. sqlseed_web-0.2.4.dist-info/METADATA +105 -0
  84. sqlseed_web-0.2.4.dist-info/RECORD +87 -0
  85. sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
  86. sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
  87. sqlseed_web-0.2.4.dist-info/licenses/LICENSE +679 -0
@@ -0,0 +1,432 @@
1
+ // 自定义下拉组件:展开时放到当前 overlay,避免 modal-body 的滚动裁切。
2
+ // 不用原生 <select> 的原因:原生选项弹层由浏览器 UI 层渲染,在嵌入式
3
+ // WebView / IDE 预览浏览器中会出现定位错乱(脱离控件),页面 CSS 无法
4
+ // 干预。此组件在任何环境下行为一致。
5
+ //
6
+ // 用法:
7
+ // const dd = createDropdown({
8
+ // value: 'sqlite',
9
+ // options: [{ value: 'sqlite', label: '本地数据库文件(SQLite)' }, ...],
10
+ // onChange: (v) => { form.kind = v; },
11
+ // });
12
+ // container.append(dd.el); // 挂载
13
+ // dd.get() // 读当前值
14
+ // dd.set(v) // 程序化设值(不触发 onChange)
15
+ // dd.setOptions(opts, value?) // 替换选项(异步数据加载后)
16
+
17
+ import { h, clear } from './api.js';
18
+ let dropdownSequence = 0;
19
+ export function createDropdown({
20
+ value = '',
21
+ options = [],
22
+ onChange,
23
+ placeholder = '— 选择 —',
24
+ width,
25
+ label,
26
+ labelledBy
27
+ }) {
28
+ function navigateOptions(e, expanded, move, printable) {
29
+ const enabled = enabledIndices();
30
+ const position = enabled.indexOf(state.active);
31
+ if (e.key === 'Home' || !expanded && e.key === 'ArrowUp') {
32
+ setActive(enabled[0] ?? -1);
33
+ } else if (e.key === 'End') {
34
+ setActive(enabled.at(-1) ?? -1);
35
+ } else if (move && (expanded || e.key.startsWith('Page'))) {
36
+ let delta;
37
+ if (e.key === 'PageDown') {
38
+ delta = 10;
39
+ } else if (e.key === 'PageUp') {
40
+ delta = -10;
41
+ } else if (e.key === 'ArrowDown') {
42
+ delta = 1;
43
+ } else {
44
+ delta = -1;
45
+ }
46
+ setActive(enabled[Math.max(0, Math.min(enabled.length - 1, position + delta))] ?? -1);
47
+ } else if (printable) {
48
+ searchOptions(e, enabled, position);
49
+ }
50
+ function searchOptions(e, enabled, position) {
51
+ const now = Date.now();
52
+ searchText = now - searchTime > 700 ? e.key : searchText + e.key;
53
+ searchTime = now;
54
+ const chars = [...searchText.toLocaleLowerCase()];
55
+ const repeated = chars.every(character => character === chars[0]);
56
+ const prefix = repeated ? chars[0] : chars.join('');
57
+ const start = Math.max(0, position + (prefix.length === 1 ? 1 : 0));
58
+ for (let offset = 0; offset < enabled.length; offset++) {
59
+ const index = enabled[(start + offset) % enabled.length];
60
+ if (String(state.options[index].label).toLocaleLowerCase().startsWith(prefix)) {
61
+ setActive(index);
62
+ break;
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ function dismissKey(e, expanded) {
69
+ if (e.key === 'Tab') {
70
+ if (expanded) {
71
+ chooseActive(false);
72
+ }
73
+ return true; // Keep the browser's normal forward/backward focus movement.
74
+ }
75
+ if (e.key === 'Escape') {
76
+ if (expanded) {
77
+ e.preventDefault();
78
+ e.stopImmediatePropagation();
79
+ close();
80
+ btn.focus();
81
+ }
82
+ return true;
83
+ }
84
+ return false;
85
+ }
86
+
87
+ const state = {
88
+ value: '',
89
+ options: [],
90
+ active: -1
91
+ };
92
+ const id = `sqlseed-dropdown-${++dropdownSequence}`;
93
+ let itemElements = [],
94
+ searchText = '',
95
+ searchTime = 0,
96
+ destroyed = false;
97
+ let generatedLabel = label || (placeholder === '— 选择 —' ? '选择选项' : placeholder);
98
+ const btn = h('button', {
99
+ class: 'dropdown-btn',
100
+ type: 'button',
101
+ id,
102
+ role: 'combobox',
103
+ tabindex: 0,
104
+ 'aria-expanded': 'false',
105
+ 'aria-haspopup': 'listbox',
106
+ 'aria-controls': `${id}-list`,
107
+ ...(labelledBy ? {
108
+ 'aria-labelledby': labelledBy
109
+ } : {
110
+ 'aria-label': generatedLabel
111
+ }),
112
+ onclick: toggle,
113
+ onkeydown: onKey,
114
+ onfocus: syncName
115
+ }, h('span', {
116
+ class: 'dropdown-btn-label'
117
+ }, placeholder));
118
+ const panel = h('div', {
119
+ class: 'dropdown-panel',
120
+ id: `${id}-list`,
121
+ role: 'listbox',
122
+ 'aria-labelledby': id,
123
+ hidden: true
124
+ });
125
+ const el = h('div', {
126
+ class: 'dropdown'
127
+ }, btn, panel);
128
+ if (width) {
129
+ el.style.minWidth = width;
130
+ }
131
+
132
+ // Keep the caption as the accessible name without letting its native label
133
+ // default action forward a second click to the select-only trigger.
134
+ const onLabelClick = event => {
135
+ const wrapper = el.closest('label');
136
+ if (!wrapper?.contains(event.target) || el.contains(event.target)) {
137
+ return;
138
+ }
139
+ if (event.target.closest?.('a, button, input, textarea, select')) {
140
+ return;
141
+ }
142
+ event.preventDefault();
143
+ };
144
+ document.addEventListener('click', onLabelClick, true);
145
+
146
+ // 关闭面板:点击组件外部或按 Escape。
147
+ const onDocClick = e => {
148
+ if (!el.contains(e.target) && !panel.contains(e.target)) {
149
+ close();
150
+ }
151
+ };
152
+ function syncName() {
153
+ // Hosts may name either the trigger or the composite control after creation.
154
+ if (btn.getAttribute('aria-labelledby')) {
155
+ return;
156
+ }
157
+ if (btn.getAttribute('aria-label') !== generatedLabel) {
158
+ return;
159
+ }
160
+ const wrapper = el.closest('label');
161
+ const wrappedLabel = wrapper ? [...wrapper.childNodes].filter(node => node !== el).map(node => node.textContent).join('').trim() : '';
162
+ generatedLabel = el.getAttribute('aria-label') || wrappedLabel || label || generatedLabel;
163
+ btn.setAttribute('aria-label', generatedLabel);
164
+ }
165
+ function onKey(e) {
166
+ if (!acceptsKeyEvent(e)) {
167
+ return;
168
+ }
169
+ const expanded = el.classList.contains('open');
170
+ if (dismissKey(e, expanded)) {
171
+ return;
172
+ }
173
+ const move = ['ArrowDown', 'ArrowUp', 'Home', 'End', 'PageDown', 'PageUp'].includes(e.key);
174
+ const choose = e.key === 'Enter' || e.key === ' ';
175
+ const printable = e.key?.length === 1 && !e.altKey;
176
+ if (!move && !choose && !printable) {
177
+ return;
178
+ }
179
+ if (e.altKey && !['ArrowDown', 'ArrowUp'].includes(e.key)) {
180
+ return;
181
+ }
182
+ e.preventDefault();
183
+ e.stopPropagation();
184
+ if (choose && expanded) {
185
+ chooseActive();
186
+ return;
187
+ }
188
+ if (!expanded) {
189
+ open();
190
+ }
191
+ if (choose) {
192
+ return;
193
+ }
194
+ if (e.altKey) {
195
+ if (e.key === 'ArrowUp') {
196
+ chooseActive();
197
+ }
198
+ return;
199
+ }
200
+ navigateOptions(e, expanded, move, printable);
201
+ function acceptsKeyEvent(e) {
202
+ if (destroyed || btn.disabled || e.isComposing || e.ctrlKey || e.metaKey) {
203
+ return false;
204
+ }
205
+ if (e.target && e.target !== document && !el.contains(e.target) && !panel.contains(e.target)) {
206
+ return false;
207
+ }
208
+ return true;
209
+ }
210
+ }
211
+ function enabledIndices() {
212
+ return state.options.flatMap((option, index) => option.disabled ? [] : [index]);
213
+ }
214
+ function resetActive() {
215
+ const selected = state.options.findIndex(option => !option.disabled && option.value === state.value);
216
+ state.active = selected >= 0 ? selected : enabledIndices()[0] ?? -1;
217
+ searchText = '';
218
+ searchTime = 0;
219
+ }
220
+ function setActive(index, scroll = true) {
221
+ state.active = index;
222
+ for (let i = 0; i < itemElements.length; i++) {
223
+ const active = i === index && !state.options[i].disabled;
224
+ itemElements[i].classList.toggle('selected', active);
225
+ itemElements[i].setAttribute('aria-selected', String(active));
226
+ }
227
+ if (itemElements[index] && !state.options[index].disabled) {
228
+ btn.setAttribute('aria-activedescendant', itemElements[index].id);
229
+ if (scroll) {
230
+ itemElements[index].scrollIntoView?.({
231
+ block: 'nearest'
232
+ });
233
+ }
234
+ } else {
235
+ btn.removeAttribute('aria-activedescendant');
236
+ }
237
+ }
238
+ function chooseActive(restoreFocus = true) {
239
+ const option = state.options[state.active];
240
+ if (!option || option.disabled) {
241
+ close();
242
+ if (restoreFocus) {
243
+ btn.focus();
244
+ }
245
+ return;
246
+ }
247
+ const changed = state.value !== option.value;
248
+ set(option.value);
249
+ close();
250
+ if (restoreFocus) {
251
+ btn.focus();
252
+ }
253
+ if (changed) {
254
+ onChange?.(option.value);
255
+ }
256
+ }
257
+ function open() {
258
+ if (destroyed || btn.disabled || !el.isConnected || el.classList.contains('open')) {
259
+ return;
260
+ }
261
+ syncName();
262
+ resetActive();
263
+ el.classList.add('open');
264
+ btn.setAttribute('aria-expanded', 'true');
265
+ panel.hidden = false;
266
+ (el.closest('.overlay') || document.body).append(panel);
267
+ panel.classList.add('dropdown-floating');
268
+ document.addEventListener('mousedown', onDocClick);
269
+ document.addEventListener('focusin', onDocClick);
270
+ document.addEventListener('keydown', onKey, true);
271
+ renderPanel();
272
+ reposition();
273
+ if (!el.classList.contains('open')) {
274
+ return;
275
+ }
276
+ setActive(state.active);
277
+ btn.focus();
278
+ // 滚动翻页时保持弹层贴住控件。
279
+ document.addEventListener('scroll', reposition, {
280
+ capture: true,
281
+ passive: true
282
+ });
283
+ window.addEventListener('resize', reposition);
284
+ }
285
+ function close() {
286
+ el.classList.remove('open');
287
+ btn.setAttribute('aria-expanded', 'false');
288
+ btn.removeAttribute('aria-activedescendant');
289
+ panel.hidden = true;
290
+ searchText = '';
291
+ searchTime = 0;
292
+ panel.classList.remove('dropdown-floating');
293
+ el.append(panel);
294
+ document.removeEventListener('mousedown', onDocClick);
295
+ document.removeEventListener('focusin', onDocClick);
296
+ document.removeEventListener('keydown', onKey, true);
297
+ document.removeEventListener('scroll', reposition, {
298
+ capture: true
299
+ });
300
+ window.removeEventListener('resize', reposition);
301
+ }
302
+ function toggle() {
303
+ if (el.classList.contains('open')) {
304
+ close();
305
+ } else {
306
+ open();
307
+ }
308
+ }
309
+ function reposition() {
310
+ // 属性面板被 render() 整体重绘时(切列 / 切生成器 / AI 回填),旧的 dropdown
311
+ // 元素会被丢弃,而 scroll 监听只在 close() 里注销——面板还开着就被丢弃的话,
312
+ // 监听会泄漏下来,之后每次滚动都对已脱离文档的元素求值,并在 WebView 里抛出
313
+ // "Cannot read properties of null (reading 'getBoundingClientRect')"。
314
+ // 断开连接时主动注销,既修泄漏也顺带止住该报错。
315
+ if (!el?.isConnected) {
316
+ close();
317
+ return;
318
+ }
319
+ const r = btn.getBoundingClientRect();
320
+ if (r.bottom < 0 || r.top > innerHeight) {
321
+ close();
322
+ return;
323
+ }
324
+ const gap = 5,
325
+ margin = 8;
326
+ const below = Math.max(0, innerHeight - r.bottom - gap - margin);
327
+ const above = Math.max(0, r.top - gap - margin);
328
+ const desired = Math.min(260, panel.scrollHeight || 260);
329
+ const upwards = below < desired && above > below;
330
+ const height = Math.min(desired, upwards ? above : below);
331
+ const menuWidth = Math.min(r.width || r.right - r.left, innerWidth - margin * 2);
332
+ Object.assign(panel.style, {
333
+ position: 'fixed',
334
+ width: `${menuWidth}px`,
335
+ maxHeight: `${height}px`,
336
+ left: `${Math.max(margin, Math.min(r.left, innerWidth - menuWidth - margin))}px`,
337
+ right: 'auto',
338
+ top: `${upwards ? r.top - gap - height : r.bottom + gap}px`
339
+ });
340
+ }
341
+ function renderPanel() {
342
+ clear(panel);
343
+ itemElements = [];
344
+ if (!state.options.length) {
345
+ btn.removeAttribute('aria-activedescendant');
346
+ panel.append(h('div', {
347
+ class: 'dropdown-empty muted'
348
+ }, '(无选项)'));
349
+ return;
350
+ }
351
+ // 选项可带 group 字段(如生成器分类):组名变化时插入不可点击的组标题。
352
+ // 选项可带 disabled(如 参考工具 有、sqlseed 未实现的占位组):不可点选。
353
+ let lastGroup = null;
354
+ for (const [index, opt] of state.options.entries()) {
355
+ if (opt.group && opt.group !== lastGroup) {
356
+ lastGroup = opt.group;
357
+ panel.append(h('div', {
358
+ class: 'dropdown-group',
359
+ role: 'presentation'
360
+ }, opt.group));
361
+ }
362
+ const item = h('button', {
363
+ class: `dropdown-item${opt.disabled ? ' disabled' : ''}`,
364
+ type: 'button',
365
+ id: `${id}-option-${index}`,
366
+ role: 'option',
367
+ tabindex: -1,
368
+ 'aria-selected': 'false',
369
+ 'aria-disabled': String(Boolean(opt.disabled)),
370
+ disabled: !!opt.disabled,
371
+ onmousedown: event => event.preventDefault(),
372
+ onmouseenter: opt.disabled ? undefined : () => setActive(index),
373
+ onclick: opt.disabled ? undefined : () => {
374
+ setActive(index);
375
+ chooseActive();
376
+ }
377
+ }, opt.label);
378
+ itemElements.push(item);
379
+ panel.append(item);
380
+ }
381
+ setActive(state.active, false);
382
+ }
383
+ function renderBtn() {
384
+ const current = state.options.find(o => o.value === state.value);
385
+ btn.querySelector('.dropdown-btn-label').textContent = current ? current.label : placeholder;
386
+ }
387
+ function set(v) {
388
+ state.value = v;
389
+ renderBtn();
390
+ if (el.classList.contains('open')) {
391
+ resetActive();
392
+ renderPanel();
393
+ reposition();
394
+ setActive(state.active);
395
+ }
396
+ }
397
+ function setOptions(opts, v) {
398
+ state.options = opts;
399
+ if (v !== undefined) {
400
+ state.value = v;
401
+ }
402
+ renderBtn();
403
+ if (el.classList.contains('open')) {
404
+ resetActive();
405
+ renderPanel();
406
+ reposition();
407
+ setActive(state.active);
408
+ }
409
+ }
410
+ state.value = value;
411
+ state.options = options;
412
+ renderBtn();
413
+
414
+ /**
415
+ * 彻底注销监听器。宿主(如 genform)重绘并丢弃本组件时必须调用——
416
+ * 监听只在 close() 里注销,面板开着就被丢弃会泄漏。
417
+ */
418
+ function destroy() {
419
+ destroyed = true;
420
+ close();
421
+ document.removeEventListener('click', onLabelClick, true);
422
+ btn.removeEventListener('keydown', onKey);
423
+ btn.removeEventListener('focus', syncName);
424
+ }
425
+ return {
426
+ el,
427
+ get: () => state.value,
428
+ set,
429
+ setOptions,
430
+ destroy
431
+ };
432
+ }
@@ -0,0 +1,203 @@
1
+ // 文件选择器:服务器端目录浏览模态框。
2
+ // 浏览器安全模型不暴露本地绝对路径,但 UI 服务器就跑在本机,
3
+ // 通过 /api/fs/browse 列目录实现真正的"选择文件"按钮。
4
+
5
+ import { h, get, clear, msg } from './api.js';
6
+
7
+ /**
8
+ * 打开服务端目录浏览模态框。
9
+ * @param {object} opts
10
+ * @param {'file'|'dir'} [opts.mode] file = 选数据库文件(默认);dir = 选文件夹
11
+ * (bytes 生成器的 folder 参数用 dir 模式,浏览器无法给出本地绝对路径)
12
+ * @param {string} [opts.startPath] 起始目录(默认用户主目录)
13
+ * @param {(path: string) => void} opts.onPick 选中后的回调
14
+ */
15
+ export function openFilePicker({
16
+ mode = 'file',
17
+ startPath,
18
+ onPick
19
+ }) {
20
+ const isDir = mode === 'dir';
21
+ const overlay = h('div', {
22
+ class: 'modal-overlay',
23
+ onclick: e => {
24
+ if (e.target === overlay) {
25
+ close();
26
+ }
27
+ }
28
+ });
29
+ const listing = h('div', {
30
+ class: 'file-list'
31
+ });
32
+ const crumbs = h('div', {
33
+ class: 'file-crumbs'
34
+ });
35
+ const statusEl = h('div', {
36
+ class: 'muted'
37
+ }, '加载中…');
38
+ const pathInput = h('input', {
39
+ class: 'file-path-input',
40
+ spellcheck: 'false',
41
+ placeholder: '目录路径',
42
+ onkeydown: e => {
43
+ if (e.key === 'Enter') {
44
+ load(e.target.value.trim() || null);
45
+ }
46
+ }
47
+ });
48
+ // 选文件夹时默认列出全部文件(否则目录里的非 DB 文件会被过滤掉,看不出内容)。
49
+ const showAll = {
50
+ value: isDir
51
+ };
52
+ let current = null;
53
+ let selected = null;
54
+ const dialog = h('div', {
55
+ class: 'modal'
56
+ }, h('h3', {}, isDir ? '选择文件夹' : '选择数据库文件'), h('div', {
57
+ class: 'row'
58
+ }, pathInput, h('button', {
59
+ class: 'small',
60
+ onclick: () => load(pathInput.value.trim() || null)
61
+ }, '转到'), h('button', {
62
+ class: 'small',
63
+ onclick: () => load(null)
64
+ }, '主目录'), h('label', {
65
+ class: 'muted'
66
+ }, h('input', {
67
+ type: 'checkbox',
68
+ onchange: e => {
69
+ showAll.value = e.target.checked;
70
+ load(current);
71
+ }
72
+ }), '显示全部文件')), crumbs, h('div', {
73
+ class: 'file-list-wrap'
74
+ }, listing), statusEl, h('div', {
75
+ class: 'row end'
76
+ }, h('button', {
77
+ onclick: close
78
+ }, '取消'),
79
+ // file 模式:点文件即选中并关闭,确认按钮保持禁用(仅作占位)。
80
+ // dir 模式:目录只能逐级进入,靠确认按钮选中「当前所在目录」。
81
+ isDir ? h('button', {
82
+ class: 'primary',
83
+ onclick: () => {
84
+ if (current) {
85
+ onPick(current);
86
+ }
87
+ close();
88
+ }
89
+ }, '选择此文件夹') : h('button', {
90
+ class: 'primary',
91
+ disabled: true,
92
+ id: 'fp-confirm'
93
+ }, '选择')));
94
+ function close() {
95
+ overlay.remove();
96
+ }
97
+ function pick(path, isDb) {
98
+ if (selected) {
99
+ selected.classList.remove('selected');
100
+ }
101
+ if (!isDb) {
102
+ selected = null;
103
+ document.getElementById('fp-confirm').disabled = true;
104
+ return;
105
+ }
106
+ selected = null;
107
+ onPick(path);
108
+ close();
109
+ }
110
+ function renderCrumbs(path) {
111
+ clear(crumbs);
112
+ const parts = path.split('/').filter(Boolean);
113
+ crumbs.append(h('button', {
114
+ class: 'small',
115
+ onclick: () => load('/')
116
+ }, '/'));
117
+ let acc = '';
118
+ for (const part of parts) {
119
+ acc += `/${part}`;
120
+ const target = acc;
121
+ crumbs.append(h('span', {
122
+ class: 'muted'
123
+ }, '›'), h('button', {
124
+ class: 'small',
125
+ onclick: () => load(target)
126
+ }, part));
127
+ }
128
+ }
129
+ async function load(path) {
130
+ clear(listing);
131
+ statusEl.textContent = '加载中…';
132
+ try {
133
+ const q = path ? `?path=${encodeURIComponent(path)}` : '';
134
+ const fileFilterQuery = () => {
135
+ if (showAll.value) {
136
+ return (q ? '&' : '?') + 'all_files=true';
137
+ } else {
138
+ return '';
139
+ }
140
+ };
141
+ const res = await get(`/api/fs/browse${q}${fileFilterQuery()}`);
142
+ current = res.path;
143
+ pathInput.value = res.path;
144
+ renderCrumbs(res.path);
145
+ clear(listing);
146
+ statusEl.textContent = `${res.entries.length} 个条目`;
147
+ if (!res.entries.length) {
148
+ listing.append(h('div', {
149
+ class: 'muted',
150
+ style: 'padding:16px'
151
+ }, '(空目录)'));
152
+ }
153
+ for (const entry of res.entries) {
154
+ const entryKindLabel = () => {
155
+ if (entry.is_dir) {
156
+ return 'DIR';
157
+ } else if (entry.is_db) {
158
+ return 'DB';
159
+ } else {
160
+ return '··';
161
+ }
162
+ };
163
+ listing.append(h('div', {
164
+ class: `file-entry${entry.is_db ? ' db' : ''}${entry.is_dir ? ' dir' : ''}`,
165
+ role: 'button',
166
+ tabindex: '0',
167
+ onclick: () => entry.is_dir ? load(entry.path) : pick(entry.path, entry.is_db),
168
+ onkeydown: e => {
169
+ if (e.key === 'Enter' || e.key === ' ') {
170
+ e.preventDefault();
171
+ entry.is_dir ? load(entry.path) : pick(entry.path, entry.is_db);
172
+ }
173
+ },
174
+ title: entry.path
175
+ }, h('span', {
176
+ class: 'file-icon'
177
+ }, entryKindLabel()), h('span', {
178
+ class: 'file-name'
179
+ }, entry.name), entry.size !== null ? h('span', {
180
+ class: 'muted file-size'
181
+ }, humanSize(entry.size)) : null));
182
+ }
183
+ } catch (e) {
184
+ statusEl.textContent = '';
185
+ listing.append(msg(`无法浏览:${e.message}`));
186
+ }
187
+ }
188
+ document.body.append(overlay);
189
+ overlay.append(dialog);
190
+ load(startPath || null);
191
+ }
192
+ function humanSize(n) {
193
+ if (n < 1024) {
194
+ return `${n} B`;
195
+ }
196
+ if (n < 1024 * 1024) {
197
+ return `${(n / 1024).toFixed(1)} KB`;
198
+ }
199
+ if (n < 1024 * 1024 * 1024) {
200
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
201
+ }
202
+ return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`;
203
+ }