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.
- sqlseed_web/AGENTS.md +106 -0
- sqlseed_web/__init__.py +24 -0
- sqlseed_web/__main__.py +8 -0
- sqlseed_web/_application.py +205 -0
- sqlseed_web/ai_settings.py +290 -0
- sqlseed_web/api.py +1102 -0
- sqlseed_web/app.py +26 -0
- sqlseed_web/managed_worker.py +184 -0
- sqlseed_web/operation_errors.py +42 -0
- sqlseed_web/plugin_environment.py +231 -0
- sqlseed_web/plugin_management.py +322 -0
- sqlseed_web/plugin_process.py +131 -0
- sqlseed_web/runtime_lifecycle.py +130 -0
- sqlseed_web/runtime_session.py +97 -0
- sqlseed_web/settings_environment.py +368 -0
- sqlseed_web/sqlite_target.py +86 -0
- sqlseed_web/state.py +348 -0
- sqlseed_web/static/AGENTS.md +180 -0
- sqlseed_web/static/ai.css +57 -0
- sqlseed_web/static/configs.css +50 -0
- sqlseed_web/static/date-picker.css +230 -0
- sqlseed_web/static/disclosure.css +149 -0
- sqlseed_web/static/graph-clarity.css +103 -0
- sqlseed_web/static/index.html +29 -0
- sqlseed_web/static/js/api.js +228 -0
- sqlseed_web/static/js/app.js +97 -0
- sqlseed_web/static/js/dropdown.js +432 -0
- sqlseed_web/static/js/filepicker.js +203 -0
- sqlseed_web/static/js/genform.js +1066 -0
- sqlseed_web/static/js/labels.js +195 -0
- sqlseed_web/static/js/pages/browse.js +209 -0
- sqlseed_web/static/js/pages/configs.js +424 -0
- sqlseed_web/static/js/pages/connect.js +332 -0
- sqlseed_web/static/js/pages/heal.js +395 -0
- sqlseed_web/static/js/pages/meta.js +110 -0
- sqlseed_web/static/js/pages/runs.js +293 -0
- sqlseed_web/static/js/pages/settings.js +942 -0
- sqlseed_web/static/js/pages/wizard.js +751 -0
- sqlseed_web/static/js/pages/workbench.js +3123 -0
- sqlseed_web/static/js/tree.js +126 -0
- sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
- sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
- sqlseed_web/static/js/workbench/ai-stream.js +116 -0
- sqlseed_web/static/js/workbench/ai.js +888 -0
- sqlseed_web/static/js/workbench/connection.js +508 -0
- sqlseed_web/static/js/workbench/date-picker.js +445 -0
- sqlseed_web/static/js/workbench/dependency-view.js +119 -0
- sqlseed_web/static/js/workbench/editor.js +1236 -0
- sqlseed_web/static/js/workbench/focus.js +11 -0
- sqlseed_web/static/js/workbench/graph-layout.js +332 -0
- sqlseed_web/static/js/workbench/graph.js +970 -0
- sqlseed_web/static/js/workbench/guidance.js +29 -0
- sqlseed_web/static/js/workbench/model.js +124 -0
- sqlseed_web/static/js/workbench/plugin-management.js +512 -0
- sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
- sqlseed_web/static/js/workbench/preview.js +572 -0
- sqlseed_web/static/js/workbench/provider-guide.js +33 -0
- sqlseed_web/static/js/workbench/recovery.js +28 -0
- sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
- sqlseed_web/static/js/workbench/session.js +174 -0
- sqlseed_web/static/js/workbench/table-data.js +186 -0
- sqlseed_web/static/js/workbench/ui.js +262 -0
- sqlseed_web/static/navigation.css +92 -0
- sqlseed_web/static/preview.css +29 -0
- sqlseed_web/static/runs.css +53 -0
- sqlseed_web/static/scrollbars.css +42 -0
- sqlseed_web/static/settings.css +108 -0
- sqlseed_web/static/style.css +3382 -0
- sqlseed_web/static/table-data.css +27 -0
- sqlseed_web/static/workbench.css +509 -0
- sqlseed_web/supervised_plugins.py +173 -0
- sqlseed_web/supervisor.py +238 -0
- sqlseed_web/workbench.py +381 -0
- sqlseed_web/workbench_ai.py +887 -0
- sqlseed_web/workbench_ai_relations.py +285 -0
- sqlseed_web/workbench_ai_stream.py +172 -0
- sqlseed_web/workbench_data.py +163 -0
- sqlseed_web/workbench_execution.py +199 -0
- sqlseed_web/workbench_runtime.py +1218 -0
- sqlseed_web/workbench_schema.py +277 -0
- sqlseed_web/workbench_store.py +458 -0
- sqlseed_web/worker_control.py +192 -0
- sqlseed_web-0.2.4.dist-info/METADATA +105 -0
- sqlseed_web-0.2.4.dist-info/RECORD +87 -0
- sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
- sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
- sqlseed_web-0.2.4.dist-info/licenses/LICENSE +679 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { cycleFocus } from "./focus.js";
|
|
2
|
+
import { h, get, send, store, rememberConnId, setConnBadge, safeTargetLabel } from '../api.js';
|
|
3
|
+
import { lockPageScroll } from './scroll-lock.js';
|
|
4
|
+
let activeDialog = null;
|
|
5
|
+
|
|
6
|
+
/** The same target picker serves first use and connection switching. */
|
|
7
|
+
export function openConnectionDialog({
|
|
8
|
+
onConnected
|
|
9
|
+
} = {}) {
|
|
10
|
+
function switchConnectionLabel(connection) {
|
|
11
|
+
if (pendingOperation?.kind === 'switch' && pendingOperation.connId === connection.conn_id) {
|
|
12
|
+
return '正在切换…';
|
|
13
|
+
} else if (connection.conn_id === store.connId) {
|
|
14
|
+
return '当前连接';
|
|
15
|
+
} else {
|
|
16
|
+
return '切换到此连接';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
activeDialog?.close();
|
|
21
|
+
const previousFocus = document.activeElement;
|
|
22
|
+
const unlockScroll = lockPageScroll();
|
|
23
|
+
const app = document.getElementById('app');
|
|
24
|
+
const previousInert = app?.inert;
|
|
25
|
+
if (app) {
|
|
26
|
+
app.inert = true;
|
|
27
|
+
}
|
|
28
|
+
let closed = false,
|
|
29
|
+
busy = false,
|
|
30
|
+
sequence = 0,
|
|
31
|
+
fileSequence = 0,
|
|
32
|
+
existingSequence = 0,
|
|
33
|
+
kind = 'sqlite';
|
|
34
|
+
let pendingOperation = null;
|
|
35
|
+
let connections = [];
|
|
36
|
+
const fields = {};
|
|
37
|
+
const error = h('p', {
|
|
38
|
+
class: 'connection-error',
|
|
39
|
+
role: 'alert'
|
|
40
|
+
});
|
|
41
|
+
const notice = h('p', {
|
|
42
|
+
class: 'connection-notice',
|
|
43
|
+
role: 'status',
|
|
44
|
+
'aria-live': 'polite'
|
|
45
|
+
});
|
|
46
|
+
const existing = h('div', {
|
|
47
|
+
class: 'connection-existing'
|
|
48
|
+
});
|
|
49
|
+
const controls = h('div', {
|
|
50
|
+
class: 'connection-fields'
|
|
51
|
+
});
|
|
52
|
+
const browser = h('section', {
|
|
53
|
+
class: 'connection-browser',
|
|
54
|
+
hidden: true,
|
|
55
|
+
'aria-label': '选择数据库文件'
|
|
56
|
+
});
|
|
57
|
+
const subtitle = h('p', {
|
|
58
|
+
class: 'muted'
|
|
59
|
+
}, '选择 SQLite 文件或连接 PostgreSQL,开始创建生成配置。');
|
|
60
|
+
const closeButton = h('button', {
|
|
61
|
+
class: 'close',
|
|
62
|
+
type: 'button',
|
|
63
|
+
'aria-label': '关闭连接窗口',
|
|
64
|
+
onclick: close
|
|
65
|
+
}, '×');
|
|
66
|
+
const submit = h('button', {
|
|
67
|
+
class: 'btn primary',
|
|
68
|
+
type: 'button',
|
|
69
|
+
onclick: connect
|
|
70
|
+
}, '连接数据库');
|
|
71
|
+
const choices = h('div', {
|
|
72
|
+
class: 'segmented',
|
|
73
|
+
role: 'group',
|
|
74
|
+
'aria-label': '数据库类型'
|
|
75
|
+
}, ...['sqlite', 'postgresql'].map(value => h('button', {
|
|
76
|
+
type: 'button',
|
|
77
|
+
class: value === kind ? 'active' : '',
|
|
78
|
+
'aria-pressed': String(value === kind),
|
|
79
|
+
onclick: () => {
|
|
80
|
+
if (busy || kind === value) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
clearSecrets();
|
|
84
|
+
kind = value;
|
|
85
|
+
error.textContent = '';
|
|
86
|
+
browser.hidden = true;
|
|
87
|
+
fileSequence++;
|
|
88
|
+
for (const button of choices.querySelectorAll('button')) {
|
|
89
|
+
const selected = button.textContent === (kind === 'sqlite' ? 'SQLite' : 'PostgreSQL');
|
|
90
|
+
button.classList.toggle('active', selected);
|
|
91
|
+
button.setAttribute('aria-pressed', String(selected));
|
|
92
|
+
}
|
|
93
|
+
renderFields();
|
|
94
|
+
}
|
|
95
|
+
}, value === 'sqlite' ? 'SQLite' : 'PostgreSQL')));
|
|
96
|
+
const overlay = h('div', {
|
|
97
|
+
class: 'overlay open connection-overlay',
|
|
98
|
+
onclick: event => {
|
|
99
|
+
if (event.target === overlay) {
|
|
100
|
+
close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}, h('section', {
|
|
104
|
+
class: 'modal connection-modal',
|
|
105
|
+
role: 'dialog',
|
|
106
|
+
'aria-modal': 'true',
|
|
107
|
+
'aria-label': '连接数据库'
|
|
108
|
+
}, h('header', {
|
|
109
|
+
class: 'modal-head'
|
|
110
|
+
}, h('h2', {}, '连接数据库'), closeButton), subtitle, existing, h('h3', {
|
|
111
|
+
class: 'connection-add-title'
|
|
112
|
+
}, '添加连接'), choices, controls, browser, error, notice, h('footer', {
|
|
113
|
+
class: 'modal-footer connection-footer'
|
|
114
|
+
}, h('button', {
|
|
115
|
+
class: 'btn',
|
|
116
|
+
type: 'button',
|
|
117
|
+
onclick: close
|
|
118
|
+
}, '取消'), submit)));
|
|
119
|
+
function clearSecrets() {
|
|
120
|
+
if (fields.password) {
|
|
121
|
+
fields.password.value = '';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function close() {
|
|
125
|
+
if (closed) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
closed = true;
|
|
129
|
+
sequence++;
|
|
130
|
+
fileSequence++;
|
|
131
|
+
existingSequence++;
|
|
132
|
+
clearSecrets();
|
|
133
|
+
overlay.remove();
|
|
134
|
+
unlockScroll();
|
|
135
|
+
document.removeEventListener('keydown', keydown);
|
|
136
|
+
if (app) {
|
|
137
|
+
app.inert = previousInert;
|
|
138
|
+
}
|
|
139
|
+
previousFocus?.focus?.({
|
|
140
|
+
preventScroll: true
|
|
141
|
+
});
|
|
142
|
+
if (activeDialog?.close === close) {
|
|
143
|
+
activeDialog = null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function field(name, label, value = '', type = 'text') {
|
|
147
|
+
const input = h('input', {
|
|
148
|
+
name,
|
|
149
|
+
value,
|
|
150
|
+
type,
|
|
151
|
+
spellcheck: 'false',
|
|
152
|
+
autocomplete: type === 'password' ? 'current-password' : 'off'
|
|
153
|
+
});
|
|
154
|
+
fields[name] = input;
|
|
155
|
+
return h('label', {
|
|
156
|
+
class: 'connection-field'
|
|
157
|
+
}, h('span', {}, label), input);
|
|
158
|
+
}
|
|
159
|
+
function renderFields() {
|
|
160
|
+
for (const name of Object.keys(fields)) {
|
|
161
|
+
delete fields[name];
|
|
162
|
+
}
|
|
163
|
+
if (kind === 'sqlite') {
|
|
164
|
+
controls.replaceChildren(field('db_path', '数据库文件'), h('button', {
|
|
165
|
+
type: 'button',
|
|
166
|
+
class: 'btn',
|
|
167
|
+
onclick: () => {
|
|
168
|
+
browser.hidden = false;
|
|
169
|
+
browseFiles();
|
|
170
|
+
}
|
|
171
|
+
}, '选择文件'));
|
|
172
|
+
fields.db_path.placeholder = '/path/to/database.sqlite3';
|
|
173
|
+
} else {
|
|
174
|
+
controls.replaceChildren(h('div', {
|
|
175
|
+
class: 'connection-pair'
|
|
176
|
+
}, field('host', '主机', 'localhost'), field('port', '端口', '5432', 'number')), field('database', '数据库名称'), h('div', {
|
|
177
|
+
class: 'connection-pair'
|
|
178
|
+
}, field('user', '用户名'), field('password', '密码', '', 'password')));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function setBusy(operation = null) {
|
|
182
|
+
const previous = pendingOperation;
|
|
183
|
+
pendingOperation = operation;
|
|
184
|
+
busy = !!operation;
|
|
185
|
+
submit.disabled = busy;
|
|
186
|
+
submit.textContent = operation?.kind === 'add' ? '正在添加连接…' : '连接数据库';
|
|
187
|
+
controls.setAttribute('aria-busy', String(busy));
|
|
188
|
+
existing.setAttribute('aria-busy', String(busy));
|
|
189
|
+
if (operation) {
|
|
190
|
+
notice.textContent = operation.message;
|
|
191
|
+
fileSequence++;
|
|
192
|
+
browser.hidden = true;
|
|
193
|
+
} else if (notice.textContent === previous?.message) {
|
|
194
|
+
notice.textContent = '';
|
|
195
|
+
}
|
|
196
|
+
for (const input of controls.querySelectorAll('input,button')) {
|
|
197
|
+
input.disabled = busy;
|
|
198
|
+
}
|
|
199
|
+
for (const button of choices.querySelectorAll('button')) {
|
|
200
|
+
button.disabled = busy;
|
|
201
|
+
}
|
|
202
|
+
renderExisting();
|
|
203
|
+
}
|
|
204
|
+
function publish(connection) {
|
|
205
|
+
store.connId = connection.conn_id;
|
|
206
|
+
const target = connection.target_label || connection.target;
|
|
207
|
+
store.target = String(target || '').includes('://') ? safeTargetLabel(target) : target;
|
|
208
|
+
store.tables = connection.tables || [];
|
|
209
|
+
rememberConnId(connection.conn_id);
|
|
210
|
+
setConnBadge();
|
|
211
|
+
close();
|
|
212
|
+
window.dispatchEvent(new Event('sqlseed:connection-changed'));
|
|
213
|
+
onConnected?.({
|
|
214
|
+
conn_id: store.connId,
|
|
215
|
+
target_label: safeTargetLabel(store.target),
|
|
216
|
+
tables: store.tables
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
function payload() {
|
|
220
|
+
if (kind === 'sqlite') {
|
|
221
|
+
const dbPath = fields.db_path.value.trim();
|
|
222
|
+
if (!dbPath) {
|
|
223
|
+
throw new Error('请选择或输入数据库文件路径。');
|
|
224
|
+
}
|
|
225
|
+
// A connection is an adapter target. Generator defaults belong to the
|
|
226
|
+
// document; BaseProvider keeps connecting independent of optional extras.
|
|
227
|
+
return {
|
|
228
|
+
db_path: dbPath,
|
|
229
|
+
provider: 'base'
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
const hostname = fields.host.value.trim(),
|
|
233
|
+
database = fields.database.value.trim(),
|
|
234
|
+
user = fields.user.value.trim();
|
|
235
|
+
const port = fields.port.value.trim();
|
|
236
|
+
if (!hostname || !database || !user) {
|
|
237
|
+
throw new Error('请填写主机、数据库名称和用户名。');
|
|
238
|
+
}
|
|
239
|
+
if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
|
|
240
|
+
throw new Error('端口必须是 1 到 65535 之间的整数。');
|
|
241
|
+
}
|
|
242
|
+
const host = hostname.includes(':') && !hostname.startsWith('[') ? `[${hostname}]` : hostname;
|
|
243
|
+
return {
|
|
244
|
+
url: `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(fields.password.value)}@${host}:${port}/${encodeURIComponent(database)}`,
|
|
245
|
+
provider: 'base'
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
async function connect() {
|
|
249
|
+
if (closed || busy) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
error.textContent = '';
|
|
253
|
+
let data;
|
|
254
|
+
try {
|
|
255
|
+
data = payload();
|
|
256
|
+
} catch (error_) {
|
|
257
|
+
error.textContent = error_.message;
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const expected = ++sequence;
|
|
261
|
+
setBusy({
|
|
262
|
+
kind: 'add',
|
|
263
|
+
message: '正在添加连接,请等待数据库响应…'
|
|
264
|
+
});
|
|
265
|
+
try {
|
|
266
|
+
const connection = await send('/api/connections', data);
|
|
267
|
+
if (!closed && expected === sequence) {
|
|
268
|
+
publish(connection);
|
|
269
|
+
}
|
|
270
|
+
} catch (error_) {
|
|
271
|
+
if (!closed && expected === sequence) error.textContent = connectionError(error_.message);
|
|
272
|
+
} finally {
|
|
273
|
+
clearSecrets();
|
|
274
|
+
if (!closed && expected === sequence) {
|
|
275
|
+
setBusy();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
async function useConnection(connection) {
|
|
280
|
+
if (closed || busy || connection.conn_id === store.connId) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const expected = ++sequence;
|
|
284
|
+
error.textContent = '';
|
|
285
|
+
setBusy({
|
|
286
|
+
kind: 'switch',
|
|
287
|
+
connId: connection.conn_id,
|
|
288
|
+
message: `正在切换到 ${safeTargetLabel(connection.target_label || connection.target)},读取表结构…`
|
|
289
|
+
});
|
|
290
|
+
try {
|
|
291
|
+
const detail = await get(`/api/connections/${encodeURIComponent(connection.conn_id)}/tables`);
|
|
292
|
+
if (!closed && expected === sequence) {
|
|
293
|
+
publish({
|
|
294
|
+
...detail,
|
|
295
|
+
conn_id: connection.conn_id
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
} catch (error_) {
|
|
299
|
+
if (!closed && expected === sequence) error.textContent = connectionError(error_.message);
|
|
300
|
+
} finally {
|
|
301
|
+
if (!closed && expected === sequence) {
|
|
302
|
+
setBusy();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
async function disconnect(connection) {
|
|
307
|
+
if (closed || busy) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const expected = ++sequence;
|
|
311
|
+
existingSequence++;
|
|
312
|
+
error.textContent = '';
|
|
313
|
+
setBusy({
|
|
314
|
+
kind: 'disconnect',
|
|
315
|
+
connId: connection.conn_id,
|
|
316
|
+
message: `正在断开 ${safeTargetLabel(connection.target_label || connection.target)}…`
|
|
317
|
+
});
|
|
318
|
+
try {
|
|
319
|
+
await send(`/api/connections/${encodeURIComponent(connection.conn_id)}`, undefined, 'DELETE');
|
|
320
|
+
// The server close still took place if the dialog was closed meanwhile.
|
|
321
|
+
// Clear only the removed current session, never a more recent selection.
|
|
322
|
+
if (store.connId === connection.conn_id) {
|
|
323
|
+
store.connId = null;
|
|
324
|
+
store.target = null;
|
|
325
|
+
store.tables = [];
|
|
326
|
+
rememberConnId('');
|
|
327
|
+
setConnBadge();
|
|
328
|
+
window.dispatchEvent(new Event('sqlseed:connection-changed'));
|
|
329
|
+
}
|
|
330
|
+
if (!closed && expected === sequence) {
|
|
331
|
+
connections = connections.filter(item => item.conn_id !== connection.conn_id);
|
|
332
|
+
notice.textContent = `已断开 ${safeTargetLabel(connection.target_label || connection.target)},并从本次会话列表移除。数据库文件和数据保持不变。`;
|
|
333
|
+
renderExisting();
|
|
334
|
+
}
|
|
335
|
+
} catch (error_) {
|
|
336
|
+
if (!closed && expected === sequence) error.textContent = connectionError(error_.message);
|
|
337
|
+
} finally {
|
|
338
|
+
if (!closed && expected === sequence) {
|
|
339
|
+
setBusy();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function renderExisting() {
|
|
344
|
+
if (!connections.length) {
|
|
345
|
+
existing.replaceChildren();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const groups = new Map();
|
|
349
|
+
for (const connection of connections) {
|
|
350
|
+
const key = connection.group_key || connection.target || connection.conn_id;
|
|
351
|
+
if (!groups.has(key)) {
|
|
352
|
+
groups.set(key, []);
|
|
353
|
+
}
|
|
354
|
+
groups.get(key).push(connection);
|
|
355
|
+
}
|
|
356
|
+
existing.replaceChildren(h('h3', {
|
|
357
|
+
class: 'connection-label'
|
|
358
|
+
}, '已连接的数据库'), h('p', {
|
|
359
|
+
class: 'muted connection-session-help'
|
|
360
|
+
}, '切换连接后查看对应配置。断开并移除只关闭本次服务会话,不会删除数据库或运行记录。'), ...[...groups.values()].map(group => {
|
|
361
|
+
const target = group[0].target_label || group[0].target;
|
|
362
|
+
return h('section', {
|
|
363
|
+
class: 'connection-group'
|
|
364
|
+
}, h('header', {
|
|
365
|
+
class: 'connection-group-heading'
|
|
366
|
+
}, h('strong', {}, safeTargetLabel(target)), h('small', {}, `${group.length} 个会话`)), h('p', {
|
|
367
|
+
class: 'mono connection-target'
|
|
368
|
+
}, targetDescription(target)), ...group.map((connection, index) => h('div', {
|
|
369
|
+
class: 'connection-session'
|
|
370
|
+
}, h('button', {
|
|
371
|
+
class: 'connection-card',
|
|
372
|
+
type: 'button',
|
|
373
|
+
disabled: busy || connection.conn_id === store.connId,
|
|
374
|
+
onclick: () => useConnection(connection),
|
|
375
|
+
'aria-label': `${connection.conn_id === store.connId ? '当前连接' : '切换到'} ${safeTargetLabel(target)} 会话 ${index + 1}`
|
|
376
|
+
}, h('span', {}, `会话 ${index + 1}`), h('small', {}, switchConnectionLabel(connection))), h('button', {
|
|
377
|
+
class: 'btn small connection-disconnect',
|
|
378
|
+
type: 'button',
|
|
379
|
+
disabled: busy,
|
|
380
|
+
'aria-label': `断开并移除 ${safeTargetLabel(target)} 会话 ${index + 1}`,
|
|
381
|
+
onclick: () => disconnect(connection)
|
|
382
|
+
}, pendingOperation?.kind === 'disconnect' && pendingOperation.connId === connection.conn_id ? '正在断开…' : '断开并移除'))));
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
async function loadExisting() {
|
|
386
|
+
const expected = ++existingSequence;
|
|
387
|
+
try {
|
|
388
|
+
const response = await get('/api/connections');
|
|
389
|
+
if (closed || expected !== existingSequence) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
connections = response.connections || [];
|
|
393
|
+
renderExisting();
|
|
394
|
+
} catch (error_) {
|
|
395
|
+
if (!closed && expected === existingSequence) existing.replaceChildren(h('p', {
|
|
396
|
+
class: 'muted'
|
|
397
|
+
}, `已有连接暂不可用:${connectionError(error_.message)}`));
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
async function browseFiles(path) {
|
|
401
|
+
if (closed || busy) {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const expected = ++fileSequence;
|
|
405
|
+
browser.replaceChildren(h('p', {
|
|
406
|
+
role: 'status'
|
|
407
|
+
}, '正在读取文件列表…'));
|
|
408
|
+
try {
|
|
409
|
+
const response = await get(`/api/fs/browse${path ? "?path=" + encodeURIComponent(path) : ''}`);
|
|
410
|
+
if (closed || expected !== fileSequence) {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const pathInput = h('input', {
|
|
414
|
+
value: response.path,
|
|
415
|
+
'aria-label': '目录路径',
|
|
416
|
+
spellcheck: 'false'
|
|
417
|
+
});
|
|
418
|
+
const go = () => browseFiles(pathInput.value.trim());
|
|
419
|
+
pathInput.addEventListener('keydown', event => {
|
|
420
|
+
if (event.key === 'Enter') {
|
|
421
|
+
event.preventDefault();
|
|
422
|
+
go();
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
browser.replaceChildren(h('div', {
|
|
426
|
+
class: 'connection-file-path'
|
|
427
|
+
}, pathInput, h('button', {
|
|
428
|
+
class: 'btn small',
|
|
429
|
+
type: 'button',
|
|
430
|
+
onclick: go
|
|
431
|
+
}, '转到'), h('button', {
|
|
432
|
+
class: 'btn small',
|
|
433
|
+
type: 'button',
|
|
434
|
+
disabled: !response.parent,
|
|
435
|
+
onclick: () => browseFiles(response.parent)
|
|
436
|
+
}, '上一级')), h('div', {
|
|
437
|
+
class: 'connection-file-list'
|
|
438
|
+
}, ...response.entries.map(entry => h('button', {
|
|
439
|
+
type: 'button',
|
|
440
|
+
class: `connection-file${entry.is_dir ? ' directory' : ''}`,
|
|
441
|
+
onclick: () => {
|
|
442
|
+
if (entry.is_dir) {
|
|
443
|
+
browseFiles(entry.path);
|
|
444
|
+
} else {
|
|
445
|
+
fields.db_path.value = entry.path;
|
|
446
|
+
browser.hidden = true;
|
|
447
|
+
fileSequence++;
|
|
448
|
+
fields.db_path.focus();
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}, entry.name))), ...(response.entries.length ? [] : [h('p', {
|
|
452
|
+
class: 'muted'
|
|
453
|
+
}, '此目录没有数据库文件。')]));
|
|
454
|
+
} catch (error_) {
|
|
455
|
+
if (!closed && expected === fileSequence) browser.replaceChildren(h('p', {
|
|
456
|
+
role: 'alert',
|
|
457
|
+
class: 'connection-error'
|
|
458
|
+
}, connectionError(error_.message)), h('button', {
|
|
459
|
+
type: 'button',
|
|
460
|
+
class: 'btn small',
|
|
461
|
+
onclick: () => browseFiles()
|
|
462
|
+
}, '返回主目录'));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function keydown(event) {
|
|
466
|
+
if (event.key === 'Escape') {
|
|
467
|
+
event.preventDefault();
|
|
468
|
+
close();
|
|
469
|
+
}
|
|
470
|
+
if (event.key !== 'Tab') {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const candidates = [...overlay.querySelectorAll('button,input,a[href]')].filter(element => !element.disabled && !element.closest('[hidden]'));
|
|
474
|
+
cycleFocus(event, candidates);
|
|
475
|
+
}
|
|
476
|
+
renderFields();
|
|
477
|
+
document.body.append(overlay);
|
|
478
|
+
document.addEventListener('keydown', keydown);
|
|
479
|
+
closeButton.focus();
|
|
480
|
+
activeDialog = {
|
|
481
|
+
close
|
|
482
|
+
};
|
|
483
|
+
loadExisting();
|
|
484
|
+
return activeDialog;
|
|
485
|
+
}
|
|
486
|
+
function targetDescription(target) {
|
|
487
|
+
const text = String(target || '');
|
|
488
|
+
if (!text.includes('://')) {
|
|
489
|
+
return text;
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
const url = new URL(text);
|
|
493
|
+
return `${url.protocol}//${url.host}${decodeURIComponent(url.pathname)}`;
|
|
494
|
+
} catch {
|
|
495
|
+
return '数据库连接';
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function connectionError(message) {
|
|
499
|
+
const parts = String(message || '连接失败,请检查目标及连接参数。').split('://');
|
|
500
|
+
for (let index = 1; index < parts.length; index++) {
|
|
501
|
+
const authority = parts[index],
|
|
502
|
+
at = authority.indexOf('@');
|
|
503
|
+
if (at > 0 && !/[\s/]/.test(authority.slice(0, at))) {
|
|
504
|
+
parts[index] = '[credentials]' + authority.slice(at);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return parts.join('://').replace(/((?:password|sslpassword|token|secret|api_key)=)[^&\s'")]+/gi, '$1[redacted]');
|
|
508
|
+
}
|