ot-cli 0.2.1__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 (91) hide show
  1. openterminal/__init__.py +3 -0
  2. openterminal/agent/__init__.py +39 -0
  3. openterminal/agent/backend.py +180 -0
  4. openterminal/agent/build.py +115 -0
  5. openterminal/agent/middleware.py +111 -0
  6. openterminal/agent/model.py +41 -0
  7. openterminal/agent/prompt.py +63 -0
  8. openterminal/agent/runner.py +340 -0
  9. openterminal/agent/sysprobe.py +164 -0
  10. openterminal/agent/token_est.py +49 -0
  11. openterminal/app.py +52 -0
  12. openterminal/approval.py +7 -0
  13. openterminal/backend.py +3 -0
  14. openterminal/banner.py +7 -0
  15. openterminal/cli.py +426 -0
  16. openterminal/cmdset.py +7 -0
  17. openterminal/config.py +322 -0
  18. openterminal/connections/__init__.py +20 -0
  19. openterminal/connections/db.py +240 -0
  20. openterminal/connections/models.py +289 -0
  21. openterminal/connections/secrets.py +68 -0
  22. openterminal/connections_db.py +6 -0
  23. openterminal/core/__init__.py +41 -0
  24. openterminal/core/ai_flow.py +781 -0
  25. openterminal/core/auth.py +499 -0
  26. openterminal/core/cmdset_runner.py +352 -0
  27. openterminal/core/exec_events.py +650 -0
  28. openterminal/core/history.py +79 -0
  29. openterminal/core/hook_gate.py +415 -0
  30. openterminal/core/messages.py +193 -0
  31. openterminal/core/nested_ssh.py +178 -0
  32. openterminal/core/pipeline.py +1187 -0
  33. openterminal/core/pump.py +190 -0
  34. openterminal/core/transcript.py +26 -0
  35. openterminal/frontend/__init__.py +21 -0
  36. openterminal/frontend/approval.py +40 -0
  37. openterminal/frontend/banner.py +40 -0
  38. openterminal/frontend/headless.py +290 -0
  39. openterminal/frontend/render.py +41 -0
  40. openterminal/frontend/taskview.py +184 -0
  41. openterminal/frontend/term.py +1417 -0
  42. openterminal/headless.py +7 -0
  43. openterminal/history_db.py +7 -0
  44. openterminal/local_pty.py +3 -0
  45. openterminal/local_win.py +3 -0
  46. openterminal/policy.py +362 -0
  47. openterminal/render.py +7 -0
  48. openterminal/secrets_store.py +5 -0
  49. openterminal/session/__init__.py +17 -0
  50. openterminal/session/base.py +328 -0
  51. openterminal/session/local_posix.py +184 -0
  52. openterminal/session/local_win.py +173 -0
  53. openterminal/session/ssh.py +321 -0
  54. openterminal/shell/__init__.py +17 -0
  55. openterminal/shell/cmdset.py +49 -0
  56. openterminal/shell/integration.py +1298 -0
  57. openterminal/shell_integration.py +7 -0
  58. openterminal/shell_session.py +8 -0
  59. openterminal/skills/__init__.py +45 -0
  60. openterminal/skills/markdown-tables/SKILL.md +247 -0
  61. openterminal/ssh_pty.py +6 -0
  62. openterminal/sysprobe.py +7 -0
  63. openterminal/taskview.py +7 -0
  64. openterminal/term_frontend.py +7 -0
  65. openterminal/token_est.py +3 -0
  66. openterminal/transcript.py +7 -0
  67. openterminal/web/__init__.py +1 -0
  68. openterminal/web/files_api.py +789 -0
  69. openterminal/web/frontend/index.html +37 -0
  70. openterminal/web/frontend/static/app.js +3632 -0
  71. openterminal/web/frontend/static/block_stream.js +274 -0
  72. openterminal/web/frontend/static/block_view.js +383 -0
  73. openterminal/web/frontend/static/files_panel.js +782 -0
  74. openterminal/web/frontend/static/md_lite.js +127 -0
  75. openterminal/web/frontend/static/modal.js +122 -0
  76. openterminal/web/frontend/static/pad_ledger.js +46 -0
  77. openterminal/web/frontend/static/style.css +915 -0
  78. openterminal/web/frontend/static/ui/ui.css +1 -0
  79. openterminal/web/frontend/static/ui/ui.js +6292 -0
  80. openterminal/web/frontend/vendor/addon-fit.js +2 -0
  81. openterminal/web/frontend/vendor/xterm.css +8 -0
  82. openterminal/web/frontend/vendor/xterm.js +8 -0
  83. openterminal/web/protocol.py +41 -0
  84. openterminal/web/server.py +322 -0
  85. openterminal/web/worker.py +62 -0
  86. ot_cli-0.2.1.dist-info/METADATA +439 -0
  87. ot_cli-0.2.1.dist-info/RECORD +91 -0
  88. ot_cli-0.2.1.dist-info/WHEEL +5 -0
  89. ot_cli-0.2.1.dist-info/entry_points.txt +3 -0
  90. ot_cli-0.2.1.dist-info/licenses/LICENSE +202 -0
  91. ot_cli-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,782 @@
1
+ // 文件面板:远程目录浏览/上传/下载(走 REST,不占 WebSocket)。
2
+ // 面板仍是 absolute 覆盖层(不改 DOM 结构),但打开时终端让位收缩:
3
+ // pane.fp-open → .term margin-right,fit() 同步 PTY 列宽与卡片重贴。
4
+ //
5
+ // 三条硬约束:
6
+ // 1. **面板与终端完全独立**:面板自带一条 SSH 直连(不借终端的连接、
7
+ // 不经隧道、不碰 PTY),终端画面零变化,终端断开面板照常工作。
8
+ // 2. **登录弹框四栏**:IP / 端口 / 用户名 / 密码。IP 与端口默认取终端
9
+ // 连接的最终服务器、用户名默认取终端当前所处的用户,密码由用户输入。
10
+ // 勾「记住密码」后写入系统凭据库,后续打开面板自动连接、不再弹框。
11
+ // 3. **登录成功默认进当前用户目录**(SFTP 默认目录即登录用户的家目录),
12
+ // 不跟终端 cwd——跟了会把跳板机上的无关目录当用户目录列出来。
13
+ //
14
+ // 列表交互:单击=选中,双击目录=进入、双击文件=打开编辑器;下载是
15
+ // 独立的 ⬇ 按钮,不跟单击绑在一起。
16
+ class FilePanel {
17
+ constructor(session) {
18
+ this.s = session;
19
+ this.tabId = session.tabId;
20
+ this.pane = session.paneEl;
21
+ this.cwd = null; // 面板当前目录(null=尚未定位)
22
+ this._built = false;
23
+ this._conn = null; // 当前连接 {host, port, user}
24
+ this._defaults = null; // /state 给的弹框默认值(来自终端身份)
25
+ this._selected = null; // 选中的条目名(load 重渲染后回填)
26
+ this._seq = 0; // 列目录请求序号:过期响应一律丢弃
27
+ this._stateSeq = 0;
28
+ this._retryT = null; // 未就绪自动重试定时器
29
+ this._wantOpen = false; // 面板应处于展开态(重试链据此续命)
30
+ this._retryWait = 0; // 未就绪自动重试的退避
31
+ this._connecting = false; // 自动连接途中,防重入
32
+ }
33
+
34
+ toggle() {
35
+ if (!this._built) this._build();
36
+ this._wantOpen = this.el.hidden;
37
+ this.el.hidden = !this._wantOpen;
38
+ // 终端让位:面板打开时 .term 收缩(pane.fp-open → margin-right),
39
+ // fit() 走窗口 resize 同一条链把新 cols 同步给 PTY 与卡片重贴
40
+ this.pane.classList.toggle("fp-open", this._wantOpen);
41
+ if (this._wantOpen) {
42
+ this._place();
43
+ this.open();
44
+ } else {
45
+ clearTimeout(this._retryT);
46
+ }
47
+ if (this.s.fit) this.s.fit();
48
+ }
49
+
50
+ /** 会话类型探测后由 app.js 调用:本机终端没有「最终服务器」,不给入口。 */
51
+ setSupported(ok) {
52
+ const btn = document.getElementById(`filesbtn-${this.tabId}`);
53
+ if (btn) btn.hidden = !ok;
54
+ if (!ok && this.el) {
55
+ this.el.hidden = true;
56
+ this._wantOpen = false;
57
+ this.pane.classList.remove("fp-open");
58
+ }
59
+ }
60
+
61
+ _place() {
62
+ const head = this.pane.querySelector(".pane-head");
63
+ const bar = this.pane.querySelector(".statusbar");
64
+ this.el.style.top = head.offsetHeight + "px";
65
+ this.el.style.bottom = (bar ? bar.offsetHeight : 0) + "px";
66
+ }
67
+
68
+ _build() {
69
+ const el = document.createElement("div");
70
+ el.className = "files-panel";
71
+ el.hidden = true;
72
+ // 工具栏一律图标 + 原生 title(全仓库唯一的悬浮提示做法,不引 tooltip 组件)
73
+ el.innerHTML = `
74
+ <div class="fp-head">
75
+ <div class="fp-head-top">
76
+ <span class="fp-title">远程文件</span>
77
+ <span class="fp-badge"></span>
78
+ <button class="fp-btn fp-icon" data-act="close" title="关闭面板">×</button>
79
+ </div>
80
+ <div class="fp-head-acts">
81
+ <button class="fp-btn fp-icon" data-act="home" title="回到登录用户的家目录">⌂</button>
82
+ <button class="fp-btn fp-icon" data-act="link" title="连接设置:IP / 端口 / 用户名 / 密码">⚙</button>
83
+ <button class="fp-btn fp-icon" data-act="mkdir" title="新建文件夹">⊞</button>
84
+ <button class="fp-btn fp-icon" data-act="create" title="新建文件">+</button>
85
+ <button class="fp-btn fp-icon" data-act="refresh" title="刷新">⟳</button>
86
+ </div>
87
+ </div>
88
+ <div class="fp-path"></div>
89
+ <div class="fp-list" tabindex="-1"></div>
90
+ <div class="fp-progress" hidden><i></i></div>
91
+ <div class="fp-foot">
92
+ <input type="file" class="fp-input" multiple hidden>
93
+ <button class="fp-btn fp-icon fp-up" data-act="pick" title="上传文件">⬆</button>
94
+ <span class="fp-hint">可拖文件进列表上传,双击进入 / 打开</span>
95
+ </div>`;
96
+ this.pane.appendChild(el);
97
+ this.el = el;
98
+ this._built = true;
99
+
100
+ // 恒开弹框:既可连到别的主机/身份,也可在弹框里断开当前连接
101
+ el.querySelector('[data-act="link"]').onclick = () => this._connectDialog();
102
+ el.querySelector('[data-act="home"]').onclick = () => {
103
+ this.cwd = null;
104
+ this._selected = null;
105
+ this.load(".");
106
+ };
107
+ el.querySelector('[data-act="refresh"]').onclick = () => this.refresh();
108
+ el.querySelector('[data-act="close"]').onclick = () => this.toggle();
109
+ el.querySelector('[data-act="mkdir"]').onclick = () => {
110
+ this._promptName("新建文件夹", "目录名", (name) => {
111
+ const remote = this._join(this.cwd, name);
112
+ this._post("mkdir", remote, `已创建文件夹 ${name}`)
113
+ .then((ok) => { if (ok) this.load(this.cwd); });
114
+ });
115
+ };
116
+ el.querySelector('[data-act="create"]').onclick = () => {
117
+ this._promptName("新建文件", "文件名(创建后直接进编辑器)", (name) => {
118
+ const remote = this._join(this.cwd, name);
119
+ this._post("create", remote, null, {exists: `已存在同名文件 ${name}`})
120
+ .then((ok) => {
121
+ if (!ok) return;
122
+ this.load(this.cwd);
123
+ this.edit(name, remote);
124
+ });
125
+ });
126
+ };
127
+ el.querySelector('[data-act="pick"]').onclick = () => el.querySelector(".fp-input").click();
128
+ el.querySelector(".fp-input").onchange = (e) => {
129
+ this.upload(e.target.files);
130
+ e.target.value = "";
131
+ };
132
+ const list = el.querySelector(".fp-list");
133
+ list.addEventListener("dragover", (e) => { e.preventDefault(); list.classList.add("drop"); });
134
+ list.addEventListener("dragleave", () => list.classList.remove("drop"));
135
+ list.addEventListener("drop", (e) => {
136
+ e.preventDefault();
137
+ list.classList.remove("drop");
138
+ if (e.dataTransfer.files.length) this.upload(e.dataTransfer.files);
139
+ });
140
+ // 键盘等价物:↑/↓ 移动选中,Enter 打开(双击的键盘路径)
141
+ list.addEventListener("keydown", (ev) => this._onListKey(ev));
142
+ this._setBadge("");
143
+ }
144
+
145
+ // 面板请求的单点 URL 构造(连接与终端无关,没有 via 维度)
146
+ _url(action, params) {
147
+ const q = new URLSearchParams(params || {});
148
+ return `/api/tabs/${this.tabId}/files/${action}?${q.toString()}`;
149
+ }
150
+
151
+ _api(action) { return this._url(action); }
152
+
153
+ _setBadge(text) {
154
+ const badge = this.el && this.el.querySelector(".fp-badge");
155
+ if (!badge) return;
156
+ badge.textContent = text;
157
+ badge.hidden = !text;
158
+ }
159
+
160
+ // --- 打开 / 状态 / 加载 ---------------------------------------------------
161
+
162
+ /** 打开面板:已连接就列目录;没连接先试着用记住的密码连,连不上才弹框。 */
163
+ async open() {
164
+ const st = await this._fetchState();
165
+ if (!st) return;
166
+ if (!st.supported) { this.setSupported(false); return; }
167
+ this._defaults = st.defaults || null;
168
+ if (st.connected) {
169
+ this._conn = {host: st.host, port: st.port, user: st.user};
170
+ this._setBadge(this._connLabel());
171
+ this.load(this.cwd || "."); // 需求 3:默认进登录用户的家目录
172
+ return;
173
+ }
174
+ if (await this._autoConnect()) return;
175
+ this._connectDialog();
176
+ }
177
+
178
+ refresh() { return this.load(this.cwd == null ? "." : this.cwd); }
179
+
180
+ _connLabel() {
181
+ const c = this._conn;
182
+ if (!c) return "";
183
+ let t = (c.user ? c.user + "@" : "") + (c.host || "");
184
+ if (c.port && Number(c.port) !== 22) t += `:${c.port}`;
185
+ return t;
186
+ }
187
+
188
+ /**
189
+ * 用记住的密码静默登录(需求 2:记住后点开自动连,不再弹框)。
190
+ * 目标恒为终端那台(defaults)——用户手改的临时目标只管本次。
191
+ * 返回 true 表示已在列目录,调用方不必再弹框。
192
+ */
193
+ async _autoConnect() {
194
+ const d = this._defaults;
195
+ if (this._connecting || !d || !d.host || !d.has_password) return false;
196
+ this._connecting = true;
197
+ try {
198
+ return await this._connect({host: d.host, port: d.port, user: d.user,
199
+ password: "", remember: false}, false, true);
200
+ } finally {
201
+ this._connecting = false;
202
+ }
203
+ }
204
+
205
+ /** 通道不可用的统一展示 + 自动退避重试(只用于面板自己的连接在重建)。 */
206
+ _pending(reason, retryIn) {
207
+ const list = this.el.querySelector(".fp-list");
208
+ const why = reason === "need_password" ? "目标主机需要密码"
209
+ : "等待连接…";
210
+ list.innerHTML = `<div class="fp-empty">${esc(why)}</div>`;
211
+ this.el.querySelector(".fp-path").innerHTML =
212
+ `<span class="fp-muted">${esc(why)}</span>`;
213
+ if (reason === "need_password") return;
214
+ clearTimeout(this._retryT);
215
+ const wait = retryIn == null
216
+ ? Math.min(4000, (this._retryWait || 500))
217
+ : retryIn;
218
+ if (retryIn == null) this._retryWait = Math.min(4000, wait * 1.7);
219
+ this._retryT = setTimeout(() => {
220
+ if (this._wantOpen && !this.el.hidden) this.open();
221
+ }, wait);
222
+ }
223
+
224
+ async _fetchState() {
225
+ const seq = ++this._stateSeq;
226
+ let r;
227
+ try {
228
+ r = await authFetch(this._api("state"));
229
+ } catch (e) {
230
+ if (seq !== this._stateSeq) return null;
231
+ this._pending("not_ready", 1500);
232
+ return null;
233
+ }
234
+ if (seq !== this._stateSeq) return null;
235
+ if (!r.ok) {
236
+ this._pending("not_ready", 1500);
237
+ return null;
238
+ }
239
+ const st = await r.json();
240
+ if (seq !== this._stateSeq) return null;
241
+ if (st.connected) this._retryWait = 0;
242
+ return st;
243
+ }
244
+
245
+ async load(path) {
246
+ const list = this.el.querySelector(".fp-list");
247
+ const seq = ++this._seq;
248
+ list.innerHTML = `<div class="fp-empty">加载中…</div>`;
249
+ let r;
250
+ try {
251
+ r = await authFetch(this._url("list", {path: path}));
252
+ } catch (e) {
253
+ if (seq === this._seq) {
254
+ this._err(list, {detail: String(e), code: "io"},
255
+ "请求失败", () => this.load(path));
256
+ }
257
+ return;
258
+ }
259
+ if (seq !== this._seq) return; // 已有更新的请求在跑:丢弃本次
260
+ if (!r.ok) {
261
+ const j = await r.json().catch(() => ({}));
262
+ if (j.code === "auth_failed" || j.code === "not_connected") {
263
+ // 密码不对/连接没了:不是「重试」能解决的,回登录框换凭据
264
+ this._conn = null;
265
+ this._connectDialog();
266
+ return;
267
+ }
268
+ if (j.code === "not_ready") { this._pending("not_ready", 1200); return; }
269
+ this._err(list, j, "列目录失败", () => this.load(path));
270
+ return;
271
+ }
272
+ const data = await r.json();
273
+ if (seq !== this._seq) return;
274
+ this.cwd = data.path;
275
+ this._render(data.entries);
276
+ }
277
+
278
+ _err(list, j, label, onRetry) {
279
+ list.innerHTML = "";
280
+ const box = document.createElement("div");
281
+ box.className = "fp-empty";
282
+ box.appendChild(document.createTextNode(
283
+ `${label}:${(j && j.detail) || "未知错误"}`));
284
+ if (onRetry) {
285
+ const b = document.createElement("button");
286
+ b.className = "fp-btn fp-retry";
287
+ b.textContent = "重试";
288
+ b.onclick = onRetry;
289
+ box.appendChild(document.createElement("br"));
290
+ box.appendChild(b);
291
+ }
292
+ list.appendChild(box);
293
+ }
294
+
295
+ _render(entries) {
296
+ const list = this.el.querySelector(".fp-list");
297
+ list.innerHTML = "";
298
+ if (!entries.length) {
299
+ list.innerHTML = `<div class="fp-empty">(空目录)</div>`;
300
+ }
301
+ for (const e of entries) {
302
+ const row = document.createElement("div");
303
+ row.className = "fp-row" + (e.is_dir ? " dir" : "");
304
+ const icon = e.is_dir ? "📁" : "📄";
305
+ const meta = e.is_dir ? "" : `${this._fmtSize(e.size)} · ${this._fmtTime(e.mtime)}`;
306
+ row.innerHTML = `<span class="fp-icon">${icon}</span>` +
307
+ `<span class="fp-name" title="${esc(e.name)}">${esc(e.name)}</span>` +
308
+ `<span class="fp-meta">${esc(meta)}</span>`;
309
+ // 选中态在重渲染后回填(_render 整表重建,不回填就闪没了)
310
+ if (e.name === this._selected) row.classList.add("active");
311
+ const path = this._join(this.cwd, e.name);
312
+ // 单击=选中;双击才进入目录/打开文件(需求 4:下载不再绑在单击上)
313
+ row.onclick = () => {
314
+ this._selected = e.name;
315
+ for (const r of list.querySelectorAll(".fp-row")) r.classList.remove("active");
316
+ row.classList.add("active");
317
+ };
318
+ row.ondblclick = () => {
319
+ if (e.is_dir) { this._selected = null; this.load(path); }
320
+ else this.edit(e.name, path);
321
+ };
322
+ // 行内操作:悬浮出现。下载是独立按钮(仅文件——SFTP 无原生打包)
323
+ const acts = document.createElement("span");
324
+ acts.className = "fp-acts";
325
+ if (!e.is_dir) {
326
+ acts.appendChild(this._actBtn("⬇", "下载", () =>
327
+ this.download(e.name, path)));
328
+ acts.appendChild(this._actBtn("✏️", "编辑", () =>
329
+ this.edit(e.name, path)));
330
+ }
331
+ acts.appendChild(this._actBtn("🗑", "删除", () =>
332
+ this._confirmDelete(e.name, e.is_dir, path)));
333
+ row.appendChild(acts);
334
+ list.appendChild(row);
335
+ }
336
+ // 面包屑:绝对路径逐级可点;相对路径(如 ".",家目录未解析前)只显示文本。
337
+ // 每项 path 由 _crumbTargets 预先算好——handler 若闭包捕获循环里
338
+ // 累加的 acc,点击时读到的都是循环结束后的完整路径(中段回退全坏)
339
+ const crumbs = this.el.querySelector(".fp-path");
340
+ crumbs.innerHTML = "";
341
+ const targets = this._crumbTargets(this.cwd);
342
+ if (!targets) {
343
+ crumbs.textContent = this.cwd;
344
+ return;
345
+ }
346
+ targets.forEach(({label, path}, i) => {
347
+ if (i > 0) crumbs.appendChild(document.createTextNode(" / "));
348
+ const a = document.createElement("a");
349
+ a.textContent = label;
350
+ a.onclick = () => { this._selected = null; this.load(path); };
351
+ crumbs.appendChild(a);
352
+ });
353
+ }
354
+
355
+ _onListKey(ev) {
356
+ const rows = Array.from(this.el.querySelectorAll(".fp-row"));
357
+ if (!rows.length) return;
358
+ const names = rows.map((r) => r.querySelector(".fp-name").textContent);
359
+ let i = names.indexOf(this._selected);
360
+ if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
361
+ ev.preventDefault();
362
+ i = ev.key === "ArrowDown"
363
+ ? Math.min(names.length - 1, i + 1)
364
+ : Math.max(0, i < 0 ? 0 : i - 1);
365
+ this._selected = names[i];
366
+ rows.forEach((r, k) => r.classList.toggle("active", k === i));
367
+ } else if (ev.key === "Enter" && i >= 0) {
368
+ ev.preventDefault();
369
+ rows[i].dispatchEvent(new MouseEvent("dblclick"));
370
+ }
371
+ }
372
+
373
+ _crumbTargets(cwd) {
374
+ if (!cwd || !cwd.startsWith("/")) return null;
375
+ const targets = [{label: "/", path: "/"}];
376
+ let acc = "";
377
+ for (const p of cwd.split("/").filter(Boolean)) {
378
+ acc += "/" + p;
379
+ targets.push({label: p, path: acc});
380
+ }
381
+ return targets;
382
+ }
383
+
384
+ _join(base, name) {
385
+ if (!base || base === ".") return name;
386
+ return base.replace(/\/+$/, "") + "/" + name;
387
+ }
388
+
389
+ _fmtSize(n) {
390
+ if (n == null) return "";
391
+ if (n < 1024) return `${n} B`;
392
+ const units = ["KiB", "MiB", "GiB", "TiB"];
393
+ let v = n;
394
+ let i = -1;
395
+ do { v /= 1024; i++; } while (v >= 1024 && i < units.length - 1);
396
+ return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
397
+ }
398
+
399
+ _fmtTime(ts) {
400
+ if (!ts) return "";
401
+ const d = new Date(ts * 1000);
402
+ return d.toLocaleString([], { year: "numeric", month: "2-digit", day: "2-digit",
403
+ hour: "2-digit", minute: "2-digit" });
404
+ }
405
+
406
+ _progress(show, frac) {
407
+ const p = this.el.querySelector(".fp-progress");
408
+ p.hidden = !show;
409
+ if (show) {
410
+ p.querySelector("i").style.width =
411
+ frac == null ? "33%" : `${Math.max(2, Math.min(100, Math.round(frac * 100)))}%`;
412
+ }
413
+ }
414
+
415
+ _toast(msg, bad) {
416
+ let el = this.pane.querySelector(".fp-toast");
417
+ if (!el) {
418
+ el = document.createElement("div");
419
+ el.className = "fp-toast";
420
+ this.pane.appendChild(el);
421
+ }
422
+ el.textContent = msg;
423
+ el.className = "fp-toast show" + (bad ? " bad" : "");
424
+ clearTimeout(this._toastTimer);
425
+ this._toastTimer = setTimeout(() => el.classList.remove("show"), 2600);
426
+ }
427
+
428
+ /** 会话就绪后探测一次:本机终端没有「最终服务器」,直接不给入口。 */
429
+ async probeSupport() {
430
+ let ok = true;
431
+ try {
432
+ const r = await authFetch(this._api("state"));
433
+ if (r.ok) ok = !!(await r.json()).supported;
434
+ } catch (e) { ok = true; } // 探测失败先保留入口,别误伤 SSH
435
+ this.setSupported(ok);
436
+ return ok;
437
+ }
438
+
439
+ // --- 登录弹框 ------------------------------------------------------------
440
+
441
+ /**
442
+ * 四栏登录表单:IP / 端口 / 用户名 / 密码 + 「记住密码」。
443
+ * IP/端口默认=终端连接的最终服务器,用户名默认=终端当前所处的用户(需求 1)。
444
+ * 密码栏恒空(不预填明文);留空表示用已记住的。
445
+ */
446
+ _connectDialog() {
447
+ const d = this._defaults || {};
448
+ const cur = this._conn || {};
449
+ const host = cur.host || d.host || "";
450
+ const port = (cur.port != null ? cur.port
451
+ : (d.port != null ? d.port : 22));
452
+ const user = cur.user || d.user || "";
453
+ const hint = [];
454
+ if (d.host && d.nested) {
455
+ hint.push(`终端当前在 <b>${esc(d.host)}</b>` +
456
+ (d.user ? `(身份 <b>${esc(d.user)}</b>)` : "") +
457
+ `,已按它预填。面板独立于终端,改这里只影响本次连接。`);
458
+ }
459
+ if (d.has_password) hint.push("该身份已记住密码,留空密码即可自动登录。");
460
+ modalShell("连接远程主机",
461
+ `<div class="form-grid">` +
462
+ `<label>IP 地址</label><input id="fp-host" value="${esc(host)}" autofocus>` +
463
+ `<label>端口</label><input id="fp-port" value="${esc(port)}">` +
464
+ `<label>用户名</label><input id="fp-user" value="${esc(user)}">` +
465
+ `<label>密码</label><input type="password" id="fp-pass" ` +
466
+ `autocomplete="new-password" placeholder="留空则用已记住的">` +
467
+ `</div>` +
468
+ `<label class="fp-remember"><input type="checkbox" id="fp-remember"> ` +
469
+ `记住密码(写入系统凭据库,不落数据库)</label>` +
470
+ (hint.length ? `<p class="fp-hint2">${hint.join("<br>")}</p>` : ""),
471
+ [
472
+ {label: "连接", primary: true, onClick: () => {
473
+ const h = document.getElementById("fp-host");
474
+ const v = h.value.trim();
475
+ if (!v) { h.classList.add("invalid"); h.focus(); return false; }
476
+ const rawPort = document.getElementById("fp-port").value.trim();
477
+ return this._connect({
478
+ host: v,
479
+ port: /^\d+$/.test(rawPort) ? parseInt(rawPort, 10) : null,
480
+ user: document.getElementById("fp-user").value.trim() || null,
481
+ password: document.getElementById("fp-pass").value,
482
+ remember: document.getElementById("fp-remember").checked,
483
+ }, false);
484
+ }},
485
+ ].concat(this._conn
486
+ ? [{label: "断开连接", onClick: () => this._disconnect()}] : [])
487
+ .concat([{label: "取消"}]));
488
+ const h = document.getElementById("fp-host");
489
+ if (h) { h.focus(); if (h.select) h.select(); }
490
+ }
491
+
492
+ /**
493
+ * 登录。silent=true 时失败只退回调用方(自动连接不该打扰用户)。
494
+ * 返回 true 表示已连上并在列目录。
495
+ */
496
+ async _connect({host, port, user, password, remember}, confirmKey, silent) {
497
+ let r;
498
+ try {
499
+ r = await authFetch(this._api("connect"), {
500
+ method: "POST",
501
+ headers: {"Content-Type": "application/json"},
502
+ body: JSON.stringify({host, port, user, password,
503
+ remember: !!remember, confirm_host_key: !!confirmKey}),
504
+ });
505
+ } catch (e) {
506
+ if (!silent) this._toast(`请求失败:${e}`, true);
507
+ return false;
508
+ }
509
+ if (r.status === 428) {
510
+ // 未知主机(TOFU):展示指纹让用户确认后带 confirm 重发。
511
+ // 依赖 modal.js 的模态代际号——否则这里开的确认框会被登录框的
512
+ // close 清掉,表现为「点了连接就没反应」。
513
+ const j = await r.json().catch(() => ({}));
514
+ modalShell("确认未知主机",
515
+ `<p>${esc(j.detail || "主机密钥未知")}</p><p>信任并继续连接?</p>`,
516
+ [
517
+ {label: "信任并连接", primary: true, onClick: () =>
518
+ this._connect({host, port, user, password, remember}, true, silent)},
519
+ {label: "取消"},
520
+ ]);
521
+ return true; // 原弹窗关闭,流程转入确认框
522
+ }
523
+ if (!r.ok) {
524
+ const j = await r.json().catch(() => ({}));
525
+ if (!silent) {
526
+ // 密码错/账号无权(403 auth_failed)留在弹框便于改了重试;
527
+ // 绝不弹「访问令牌」框——401 才是它的专属语义
528
+ this._toast(j.detail || `连接失败(${r.status})`, true);
529
+ }
530
+ return false;
531
+ }
532
+ const j = await r.json();
533
+ this._conn = {host: j.host, port: j.port, user: j.user || ""};
534
+ this.cwd = null; // 需求 3:登录成功默认进用户目录
535
+ this._selected = null;
536
+ this._setBadge(this._connLabel());
537
+ this.load(".");
538
+ if (!silent) this._toast(`已连接 ${j.host}`);
539
+ return true;
540
+ }
541
+
542
+ async _disconnect() {
543
+ try {
544
+ await authFetch(this._api("disconnect"), {method: "POST"});
545
+ } catch (e) { /* 断开失败不阻断本地回退 */ }
546
+ this._conn = null;
547
+ this.cwd = null;
548
+ this._selected = null;
549
+ this._setBadge("");
550
+ this._toast("已断开");
551
+ this._connectDialog();
552
+ }
553
+
554
+ // --- 下载 / 上传 ---------------------------------------------------------
555
+
556
+ async download(name, path) {
557
+ this._progress(true, 0);
558
+ try {
559
+ const url = this._url("download", {path: path});
560
+ let r = await authFetch(url);
561
+ if (!r.ok) {
562
+ const j = await r.json().catch(() => ({}));
563
+ this._toast(`下载失败:${j.detail || r.status}`, true);
564
+ return;
565
+ }
566
+ // 流式读取:按 Content-Length 显示真实进度
567
+ const total = +(r.headers.get("content-length") || 0);
568
+ const reader = r.body.getReader();
569
+ const chunks = [];
570
+ let got = 0;
571
+ for (;;) {
572
+ const {done, value} = await reader.read();
573
+ if (done) break;
574
+ chunks.push(value);
575
+ got += value.length;
576
+ this._progress(true, total ? got / total : null);
577
+ }
578
+ const blob = new Blob(chunks);
579
+ const a = document.createElement("a");
580
+ a.href = URL.createObjectURL(blob);
581
+ a.download = name;
582
+ a.click();
583
+ setTimeout(() => URL.revokeObjectURL(a.href), 30000);
584
+ this._toast(`已下载 ${name}(${this._fmtSize(got)})`);
585
+ } catch (e) {
586
+ this._toast(`下载失败:${e}`, true);
587
+ } finally {
588
+ this._progress(false);
589
+ }
590
+ }
591
+
592
+ upload(fileList) {
593
+ if (!this.cwd) { this._toast("目录未就绪", true); return; }
594
+ const files = Array.from(fileList || []);
595
+ let chain = Promise.resolve();
596
+ files.forEach((f) => { chain = chain.then(() => this._uploadOne(f)); });
597
+ chain.then(() => this.load(this.cwd));
598
+ }
599
+
600
+ // 裸 body 上传:文件名走 query(服务端剥掉目录成分),body 就是文件字节。
601
+ // 不走 multipart——那要求 python-multipart,缺包时整个 web 服务起不来;
602
+ // 也省掉服务端临时文件,直接流进远端。
603
+ _uploadOne(f) {
604
+ return new Promise((resolve) => {
605
+ const xhr = new XMLHttpRequest();
606
+ const url = this._url("upload", {path: this.cwd, name: f.name});
607
+ xhr.open("POST", url);
608
+ const t = getToken();
609
+ if (t) xhr.setRequestHeader("x-ot-token", t);
610
+ xhr.upload.onprogress = (e) => {
611
+ if (e.lengthComputable) this._progress(true, e.loaded / e.total);
612
+ };
613
+ xhr.onload = () => {
614
+ this._progress(false);
615
+ if (xhr.status === 401) {
616
+ // 只有真的令牌问题才弹令牌框(业务错误也可能回 401)
617
+ let tokenish = true;
618
+ try {
619
+ const j = JSON.parse(xhr.responseText);
620
+ if (j && j.code && j.code !== "unauthorized") tokenish = false;
621
+ } catch (e) { /* 读不出 body 按令牌问题 */ }
622
+ if (tokenish) {
623
+ promptForToken().then(() => this._uploadOne(f).then(resolve));
624
+ return;
625
+ }
626
+ }
627
+ if (xhr.status !== 200) {
628
+ let detail = xhr.status;
629
+ try { detail = JSON.parse(xhr.responseText).detail || detail; } catch (e) { /* 保持状态码 */ }
630
+ this._toast(`上传失败 ${f.name}:${detail}`, true);
631
+ } else {
632
+ this._toast(`已上传 ${f.name}`);
633
+ }
634
+ resolve();
635
+ };
636
+ xhr.onerror = () => {
637
+ this._progress(false);
638
+ this._toast(`上传失败 ${f.name}:网络错误`, true);
639
+ resolve();
640
+ };
641
+ xhr.send(f);
642
+ });
643
+ }
644
+
645
+ // --- 行内操作 -------------------------------------------------------------
646
+
647
+ _actBtn(icon, title, onClick) {
648
+ const b = document.createElement("button");
649
+ b.className = "fp-act";
650
+ b.textContent = icon;
651
+ b.title = title;
652
+ b.onclick = (ev) => { ev.stopPropagation(); onClick(); };
653
+ // 双击行内按钮不该再触发行的 dblclick(进入目录/打开文件)
654
+ b.ondblclick = (ev) => ev.stopPropagation();
655
+ return b;
656
+ }
657
+
658
+ _promptName(title, placeholder, onOk) {
659
+ modalShell(title,
660
+ `<input type="text" id="modal-value" class="fp-name-input" ` +
661
+ `placeholder="${esc(placeholder)}" autofocus>`,
662
+ [
663
+ {label: "确定", primary: true, onClick: () => {
664
+ const v = document.getElementById("modal-value").value.trim();
665
+ if (!v) return false; // 空名不关窗
666
+ onOk(v);
667
+ }},
668
+ {label: "取消"},
669
+ ]);
670
+ const v = document.getElementById("modal-value");
671
+ if (v) v.focus();
672
+ }
673
+
674
+ _confirmDelete(name, isDir, path) {
675
+ modalShell("删除确认",
676
+ `<p>确定删除${isDir ? "文件夹" : "文件"} <b>${esc(name)}</b>` +
677
+ (isDir ? "(含其全部内容,<b>递归删除</b>)" : "") + "?不可恢复。</p>",
678
+ [
679
+ {label: "删除", primary: true, onClick: async () => {
680
+ if (await this._post("delete", path)) {
681
+ this._toast(`已删除 ${name}`);
682
+ this._selected = null;
683
+ this.load(this.cwd);
684
+ } else {
685
+ return false; // 删除失败留在确认框便于重试
686
+ }
687
+ }},
688
+ {label: "取消"},
689
+ ]);
690
+ }
691
+
692
+ /** POST 动作:okMsg 成功提示;codes 把业务码翻成更贴切的文案。 */
693
+ async _post(action, remote, okMsg, codes) {
694
+ try {
695
+ const url = this._url(action, {path: remote});
696
+ let r = await authFetch(url, {method: "POST"});
697
+ if (!r.ok) {
698
+ const j = await r.json().catch(() => ({}));
699
+ if (j.code === "auth_failed" || j.code === "not_connected") {
700
+ this._conn = null;
701
+ this._connectDialog();
702
+ return false;
703
+ }
704
+ const alt = codes && j.code && codes[j.code];
705
+ this._toast(alt || j.detail || r.status, true);
706
+ return false;
707
+ }
708
+ if (okMsg) this._toast(okMsg);
709
+ return true;
710
+ } catch (e) {
711
+ this._toast(`请求失败:${e}`, true);
712
+ return false;
713
+ }
714
+ }
715
+
716
+ async edit(name, path) {
717
+ const url = this._url("read", {path: path});
718
+ let r = await authFetch(url);
719
+ if (!r.ok) {
720
+ const j = await r.json().catch(() => ({}));
721
+ if (j.code === "auth_failed" || j.code === "not_connected") {
722
+ this._conn = null;
723
+ this._connectDialog();
724
+ return;
725
+ }
726
+ const why = j.code === "too_large"
727
+ ? `${j.detail || "文件过大"},请用 ⬇ 下载后编辑`
728
+ : `${j.detail || r.status}`;
729
+ this._toast(`读取失败:${why}`, true);
730
+ return;
731
+ }
732
+ const data = await r.json();
733
+ modalShell(`编辑文件 — ${name}`,
734
+ `<textarea id="fp-edit" class="fp-edit" spellcheck="false" wrap="off"></textarea>` +
735
+ `<p class="fp-edit-hint">${esc(data.path)} · ${this._fmtSize(data.size)} · Ctrl+S 保存</p>`,
736
+ [
737
+ {label: "保存", primary: true, onClick: async () => {
738
+ const text = document.getElementById("fp-edit").value;
739
+ if (await this._write(path, text)) { this._toast(`已保存 ${name}`); }
740
+ else return false;
741
+ }},
742
+ {label: "取消"},
743
+ ]);
744
+ const ta = document.getElementById("fp-edit");
745
+ ta.value = data.content;
746
+ ta.focus();
747
+ ta.addEventListener("keydown", (ev) => {
748
+ if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "s") {
749
+ ev.preventDefault();
750
+ document.querySelector("#modal-root .modal-btns button.primary").click();
751
+ }
752
+ });
753
+ }
754
+
755
+ async _write(remote, text) {
756
+ try {
757
+ const url = this._url("write", {path: remote});
758
+ let r = await authFetch(url, {method: "POST",
759
+ headers: {"Content-Type": "text/plain"}, body: text});
760
+ if (!r.ok) {
761
+ const j = await r.json().catch(() => ({}));
762
+ this._toast(`保存失败:${j.detail || r.status}`, true);
763
+ return false;
764
+ }
765
+ return true;
766
+ } catch (e) {
767
+ this._toast(`保存失败:${e}`, true);
768
+ return false;
769
+ }
770
+ }
771
+ }
772
+
773
+ if (typeof module !== "undefined" && module.exports) {
774
+ module.exports = {FilePanel, _test: {
775
+ join: FilePanel.prototype._join,
776
+ fmtSize: FilePanel.prototype._fmtSize,
777
+ fmtTime: FilePanel.prototype._fmtTime,
778
+ url: FilePanel.prototype._url,
779
+ crumbTargets: FilePanel.prototype._crumbTargets,
780
+ connLabel: FilePanel.prototype._connLabel,
781
+ }};
782
+ }