agent-loft 0.2.34 → 0.2.35
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.
- package/README.md +10 -3
- package/package.json +7 -1
- package/src/channels/web/api.ts +27 -1
- package/src/channels/web/desktop.ts +183 -0
- package/src/channels/web/server.ts +64 -2
- package/src/channels/web/terminal.ts +154 -0
- package/src/core/config.ts +27 -0
- package/src/core/desktop.ts +281 -0
- package/src/core/terminals.ts +308 -0
- package/src/core/users.ts +16 -1
- package/web/css/remote.css +350 -0
- package/web/css/terminal.css +243 -0
- package/web/index.html +2 -0
- package/web/lib/dom.js +13 -0
- package/web/lib/ws.js +6 -1
- package/web/settings/general.js +96 -0
- package/web/settings/users.js +23 -1
- package/web/state.js +12 -1
- package/web/views/desktop-touch.js +344 -0
- package/web/views/desktop.js +507 -0
- package/web/views/layout.js +57 -1
- package/web/views/messages.js +3 -1
- package/web/views/remote.js +136 -0
- package/web/views/sidebar.js +6 -1
- package/web/views/tasks.js +2 -0
- package/web/views/terminal-touch.js +207 -0
- package/web/views/terminal.js +540 -0
- package/web/views/topbar.js +4 -3
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { Terminal } from "/vendor/xterm.js";
|
|
2
|
+
import { FitAddon } from "/vendor/xterm-fit.js";
|
|
3
|
+
import { fill, h, icon, isTouch, toast } from "../lib/dom.js";
|
|
4
|
+
import { api } from "../lib/api.js";
|
|
5
|
+
import { S, ui } from "../state.js";
|
|
6
|
+
import { showAuth } from "./auth.js";
|
|
7
|
+
import { renderSidebar } from "./sidebar.js";
|
|
8
|
+
import { baseName, currentCwd } from "./topbar.js";
|
|
9
|
+
import { leaveRemote, openRemote } from "./remote.js";
|
|
10
|
+
import { attachTermTouch } from "./terminal-touch.js";
|
|
11
|
+
|
|
12
|
+
// ---------- 网页终端 ----------
|
|
13
|
+
// shell 跑在服务端(core/terminals.ts),这里每个标签一条 /ws/term 连接。
|
|
14
|
+
// 两种摆法:放在远程控制页里(embed),或者对话下面的底部面板(dock,Ctrl+`)。是同一块面板,挪个地方而已。
|
|
15
|
+
// 离开远程控制页、关掉面板、刷新页面都不结束 shell,下次打开时按服务端的列表接上;点标签上的 × 才结束。
|
|
16
|
+
|
|
17
|
+
const T = {
|
|
18
|
+
/** closed:藏起来(shell 照常跑)/ dock:底部面板 / embed:在远程控制页里 */
|
|
19
|
+
mode: "closed",
|
|
20
|
+
tabs: [],
|
|
21
|
+
active: null,
|
|
22
|
+
/** 手机上的 Ctrl / Alt 键:按一下,下一个字符变成 Ctrl+字符 / Alt+字符 */
|
|
23
|
+
ctrl: false,
|
|
24
|
+
alt: false,
|
|
25
|
+
loaded: false,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const narrow = () => matchMedia("(max-width: 768px)").matches;
|
|
29
|
+
const dark = matchMedia("(prefers-color-scheme: dark)");
|
|
30
|
+
|
|
31
|
+
const THEMES = {
|
|
32
|
+
light: {
|
|
33
|
+
background: "#ffffff",
|
|
34
|
+
foreground: "#1f2328",
|
|
35
|
+
cursor: "#1f2328",
|
|
36
|
+
selectionBackground: "#b6d6fd",
|
|
37
|
+
black: "#24292f",
|
|
38
|
+
red: "#cf222e",
|
|
39
|
+
green: "#116329",
|
|
40
|
+
yellow: "#9a6700",
|
|
41
|
+
blue: "#0969da",
|
|
42
|
+
magenta: "#8250df",
|
|
43
|
+
cyan: "#1b7c83",
|
|
44
|
+
white: "#6e7781",
|
|
45
|
+
brightBlack: "#57606a",
|
|
46
|
+
brightRed: "#a40e26",
|
|
47
|
+
brightGreen: "#1a7f37",
|
|
48
|
+
brightYellow: "#633c01",
|
|
49
|
+
brightBlue: "#218bff",
|
|
50
|
+
brightMagenta: "#a475f9",
|
|
51
|
+
brightCyan: "#3192aa",
|
|
52
|
+
brightWhite: "#8c959f",
|
|
53
|
+
},
|
|
54
|
+
dark: {
|
|
55
|
+
background: "#171717",
|
|
56
|
+
foreground: "#e6e6e6",
|
|
57
|
+
cursor: "#e6e6e6",
|
|
58
|
+
selectionBackground: "#264f78",
|
|
59
|
+
black: "#484f58",
|
|
60
|
+
red: "#ff7b72",
|
|
61
|
+
green: "#3fb950",
|
|
62
|
+
yellow: "#d29922",
|
|
63
|
+
blue: "#58a6ff",
|
|
64
|
+
magenta: "#bc8cff",
|
|
65
|
+
cyan: "#39c5cf",
|
|
66
|
+
white: "#b1bac4",
|
|
67
|
+
brightBlack: "#6e7681",
|
|
68
|
+
brightRed: "#ffa198",
|
|
69
|
+
brightGreen: "#56d364",
|
|
70
|
+
brightYellow: "#e3b341",
|
|
71
|
+
brightBlue: "#79c0ff",
|
|
72
|
+
brightMagenta: "#d2a8ff",
|
|
73
|
+
brightCyan: "#56d4dd",
|
|
74
|
+
brightWhite: "#f0f6fc",
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
const theme = () => (dark.matches ? THEMES.dark : THEMES.light);
|
|
78
|
+
dark.addEventListener("change", () => {
|
|
79
|
+
for (const tab of T.tabs) tab.term.options.theme = theme();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// xterm 的样式:第一次用到终端时才加载
|
|
83
|
+
document.head.append(h("link", { rel: "stylesheet", href: "/vendor/xterm.css" }));
|
|
84
|
+
|
|
85
|
+
// ---------- 面板 ----------
|
|
86
|
+
|
|
87
|
+
/** 底部面板的高度,记在本机 */
|
|
88
|
+
const savedHeight = () => Number(localStorage.getItem("aim.termH")) || 0;
|
|
89
|
+
|
|
90
|
+
function ensureDock() {
|
|
91
|
+
if (ui.termDock) return;
|
|
92
|
+
ui.termTabs = h("div", { class: "term-tabs", role: "tablist" });
|
|
93
|
+
ui.termPanes = h("div", { class: "term-panes" });
|
|
94
|
+
ui.termKeys = isTouch ? keyRow() : null;
|
|
95
|
+
ui.termDock = h(
|
|
96
|
+
"section",
|
|
97
|
+
{ class: "term-dock", hidden: true, "aria-label": "终端" },
|
|
98
|
+
h("div", { class: "term-resizer", title: "拖动调整高度,双击恢复默认", onpointerdown: startResize, ondblclick: () => setHeight(null) }),
|
|
99
|
+
h(
|
|
100
|
+
"div",
|
|
101
|
+
{ class: "term-head" },
|
|
102
|
+
ui.termTabs,
|
|
103
|
+
h("button", { class: "icon-btn term-act", title: "新建终端", onclick: () => void newTab() }, icon("plus")),
|
|
104
|
+
h("span", { class: "grow" }),
|
|
105
|
+
(ui.termMax = h("button", { class: "icon-btn term-act", onclick: () => (T.mode === "embed" ? toDock() : void openRemote("terminal")) })),
|
|
106
|
+
(ui.termHide = h("button", { class: "icon-btn term-act", title: "收起(终端在后台继续运行)", onclick: () => hide() }, icon("down"))),
|
|
107
|
+
),
|
|
108
|
+
ui.termPanes,
|
|
109
|
+
ui.termKeys,
|
|
110
|
+
);
|
|
111
|
+
ui.main.append(ui.termDock);
|
|
112
|
+
setHeight(savedHeight() || null);
|
|
113
|
+
new ResizeObserver(() => fitActive()).observe(ui.termPanes);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function setHeight(px) {
|
|
117
|
+
if (px) {
|
|
118
|
+
localStorage.setItem("aim.termH", String(Math.round(px)));
|
|
119
|
+
ui.termDock.style.setProperty("--term-h", `${Math.round(px)}px`);
|
|
120
|
+
} else {
|
|
121
|
+
localStorage.removeItem("aim.termH");
|
|
122
|
+
ui.termDock.style.removeProperty("--term-h");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function startResize(e) {
|
|
127
|
+
if (T.mode !== "dock" || e.button !== 0) return;
|
|
128
|
+
e.preventDefault();
|
|
129
|
+
const startY = e.clientY;
|
|
130
|
+
const startH = ui.termDock.getBoundingClientRect().height;
|
|
131
|
+
const max = ui.main.getBoundingClientRect().height - 160;
|
|
132
|
+
const move = (ev) => setHeight(Math.max(120, Math.min(max, startH + startY - ev.clientY)));
|
|
133
|
+
const up = () => {
|
|
134
|
+
window.removeEventListener("pointermove", move);
|
|
135
|
+
window.removeEventListener("pointerup", up);
|
|
136
|
+
document.body.classList.remove("resizing-v");
|
|
137
|
+
};
|
|
138
|
+
document.body.classList.add("resizing-v");
|
|
139
|
+
window.addEventListener("pointermove", move);
|
|
140
|
+
window.addEventListener("pointerup", up);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function renderHead() {
|
|
144
|
+
fill(
|
|
145
|
+
ui.termTabs,
|
|
146
|
+
T.tabs.map((tab) =>
|
|
147
|
+
h(
|
|
148
|
+
"div",
|
|
149
|
+
{
|
|
150
|
+
class: `term-tab${tab === T.active ? " on" : ""}${tab.ended ? " ended" : ""}`,
|
|
151
|
+
role: "tab",
|
|
152
|
+
title: `${tab.cwd}${tab.ended ? `\n${tab.ended}` : ""}`,
|
|
153
|
+
onclick: () => select(tab),
|
|
154
|
+
onauxclick: (e) => e.button === 1 && void closeTab(tab),
|
|
155
|
+
},
|
|
156
|
+
h("span", { class: "term-tab-name" }, tab.title || baseName(tab.cwd) || "终端"),
|
|
157
|
+
tab.state === "connecting" ? h("span", { class: "conv-spin", title: "正在连接" }) : null,
|
|
158
|
+
h("button", { class: "term-tab-x", title: tab.ended ? "关闭" : "结束这个终端", onclick: (e) => (e.stopPropagation(), void closeTab(tab)) }, icon("x")),
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
const embed = T.mode === "embed";
|
|
163
|
+
fill(ui.termMax, icon(embed ? "restore" : "maximize"));
|
|
164
|
+
ui.termMax.title = embed ? "放到对话下面" : "放大(远程控制)";
|
|
165
|
+
// 手机上没有底部面板
|
|
166
|
+
ui.termMax.hidden = embed && narrow();
|
|
167
|
+
ui.termHide.hidden = embed;
|
|
168
|
+
S.remoteLive.terminals = T.tabs.filter((t) => !t.ended).length;
|
|
169
|
+
renderSidebar();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderEmptyPane() {
|
|
173
|
+
if (T.tabs.length) return;
|
|
174
|
+
const status = S.remote?.terminal ?? {};
|
|
175
|
+
fill(
|
|
176
|
+
ui.termPanes,
|
|
177
|
+
h(
|
|
178
|
+
"div",
|
|
179
|
+
{ class: "term-empty" },
|
|
180
|
+
status.available === false
|
|
181
|
+
? h("div", { class: "error-text" }, status.reason ?? "这台服务器上终端用不了")
|
|
182
|
+
: [h("div", { class: "muted" }, "没有开着的终端"), h("button", { class: "btn", onclick: () => void newTab() }, icon("plus"), "新建终端")],
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 换摆法:dock / embed / closed。embed 时 host 是远程控制页里放终端的那块 */
|
|
188
|
+
function setMode(mode, host) {
|
|
189
|
+
ensureDock();
|
|
190
|
+
T.mode = mode;
|
|
191
|
+
if (mode === "embed" && host && ui.termDock.parentNode !== host) host.append(ui.termDock);
|
|
192
|
+
if (mode === "dock" && ui.termDock.parentNode !== ui.main) ui.main.append(ui.termDock);
|
|
193
|
+
ui.termDock.classList.toggle("embedded", mode === "embed");
|
|
194
|
+
ui.termDock.hidden = mode === "closed";
|
|
195
|
+
renderHead();
|
|
196
|
+
if (mode !== "closed") {
|
|
197
|
+
fitActive();
|
|
198
|
+
T.active?.term.focus();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 远程控制页切到「终端」 */
|
|
203
|
+
export async function embedIn(host) {
|
|
204
|
+
setMode("embed", host);
|
|
205
|
+
await loadTabs();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 放到对话下面:先回到进远程控制之前的地方 */
|
|
209
|
+
function toDock() {
|
|
210
|
+
if (narrow()) return;
|
|
211
|
+
if (S.view === "remote") leaveRemote();
|
|
212
|
+
setMode("dock");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Ctrl+`:底部面板开 / 关;手机上屏幕太小,打开远程控制里的终端 */
|
|
216
|
+
export async function toggleDock() {
|
|
217
|
+
if (narrow()) return openRemote("terminal");
|
|
218
|
+
if (T.mode === "dock") return hide();
|
|
219
|
+
if (S.view === "remote") leaveRemote();
|
|
220
|
+
setMode("dock");
|
|
221
|
+
await loadTabs();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 收起底部面板,shell 留着 */
|
|
225
|
+
export function hide() {
|
|
226
|
+
if (T.mode === "dock") setMode("closed");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------- 标签 ----------
|
|
230
|
+
|
|
231
|
+
/** 按服务端的列表对一下:别的设备开的接上,已经结束的标出来 */
|
|
232
|
+
async function loadTabs() {
|
|
233
|
+
let data;
|
|
234
|
+
try {
|
|
235
|
+
data = await api("/api/terminals");
|
|
236
|
+
} catch (err) {
|
|
237
|
+
toast(err.message);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (S.remote) S.remote.terminal = { enabled: data.enabled, allowed: data.allowed, available: data.available, reason: data.reason, keepAliveMin: data.keepAliveMin };
|
|
241
|
+
if (!data.allowed) return revoked();
|
|
242
|
+
const ids = new Set(data.terminals.map((t) => t.id));
|
|
243
|
+
for (const tab of T.tabs) if (!ids.has(tab.id) && !tab.ended) ended(tab, "已经结束了");
|
|
244
|
+
for (const t of data.terminals) if (!T.tabs.some((tab) => tab.id === t.id)) addTab(t, false);
|
|
245
|
+
// 第一次打开、一个都没有:直接开一个
|
|
246
|
+
if (!T.loaded && T.tabs.length === 0 && data.available) {
|
|
247
|
+
T.loaded = true;
|
|
248
|
+
await newTab();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
T.loaded = true;
|
|
252
|
+
if (!T.active && T.tabs.length) select(T.tabs[T.tabs.length - 1]);
|
|
253
|
+
renderHead();
|
|
254
|
+
renderEmptyPane();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function newTab() {
|
|
258
|
+
ensureDock();
|
|
259
|
+
// 先按面板现在的大小算好行列,shell 一开始就是对的宽度
|
|
260
|
+
const probe = T.active?.fit.proposeDimensions();
|
|
261
|
+
let info;
|
|
262
|
+
try {
|
|
263
|
+
info = await api("/api/terminals", { method: "POST", body: { cwd: currentCwd(), cols: probe?.cols, rows: probe?.rows } });
|
|
264
|
+
} catch (err) {
|
|
265
|
+
toast(err.message);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (T.mode === "closed") {
|
|
269
|
+
if (narrow()) return void openRemote("terminal");
|
|
270
|
+
setMode("dock");
|
|
271
|
+
}
|
|
272
|
+
// 服务端推的 { t: "remote" } 可能先到、已经加上了
|
|
273
|
+
select(T.tabs.find((t) => t.id === info.id) ?? addTab(info, true));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function addTab(info, focus) {
|
|
277
|
+
const term = new Terminal({
|
|
278
|
+
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
|
279
|
+
fontSize: narrow() ? 12 : 13,
|
|
280
|
+
cursorBlink: true,
|
|
281
|
+
scrollback: 5000,
|
|
282
|
+
allowProposedApi: false,
|
|
283
|
+
theme: theme(),
|
|
284
|
+
macOptionIsMeta: true,
|
|
285
|
+
});
|
|
286
|
+
const fit = new FitAddon();
|
|
287
|
+
term.loadAddon(fit);
|
|
288
|
+
const el = h("div", { class: "term-pane", hidden: true });
|
|
289
|
+
const tab = { id: info.id, cwd: info.cwd, title: "", term, fit, el, sock: null, total: -1, state: "connecting", ended: "", backoff: 500, retry: 0, size: "" };
|
|
290
|
+
T.tabs.push(tab);
|
|
291
|
+
if (T.tabs.length === 1) fill(ui.termPanes);
|
|
292
|
+
ui.termPanes.append(el);
|
|
293
|
+
term.open(el);
|
|
294
|
+
term.onData((d) => sendInput(tab, d));
|
|
295
|
+
term.onBinary((d) => sendInput(tab, d));
|
|
296
|
+
term.onTitleChange((title) => {
|
|
297
|
+
tab.title = title.trim().slice(0, 40);
|
|
298
|
+
renderHead();
|
|
299
|
+
});
|
|
300
|
+
term.attachCustomKeyEventHandler((e) => {
|
|
301
|
+
// Ctrl+Shift+C / V:复制、粘贴(Ctrl+C 留给 shell 当中断)。交给浏览器,xterm 自己会处理 copy / paste 事件
|
|
302
|
+
if (e.type === "keydown" && e.ctrlKey && e.shiftKey && (e.code === "KeyC" || e.code === "KeyV")) return false;
|
|
303
|
+
// Ctrl+`:收起 / 打开面板
|
|
304
|
+
if (e.type === "keydown" && e.ctrlKey && e.code === "Backquote") return false;
|
|
305
|
+
return true;
|
|
306
|
+
});
|
|
307
|
+
// 手机:手指翻历史、长按选字
|
|
308
|
+
if (isTouch) attachTermTouch(tab, (d) => sendInput(tab, d));
|
|
309
|
+
connect(tab);
|
|
310
|
+
if (focus) select(tab);
|
|
311
|
+
else renderHead();
|
|
312
|
+
return tab;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function select(tab) {
|
|
316
|
+
T.active = tab;
|
|
317
|
+
for (const t of T.tabs) t.el.hidden = t !== tab;
|
|
318
|
+
renderHead();
|
|
319
|
+
fitActive();
|
|
320
|
+
if (T.mode !== "closed") tab.term.focus();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function closeTab(tab) {
|
|
324
|
+
if (!tab.ended) {
|
|
325
|
+
try {
|
|
326
|
+
await api(`/api/terminals/${encodeURIComponent(tab.id)}`, { method: "DELETE" });
|
|
327
|
+
} catch (err) {
|
|
328
|
+
if (err.status !== 404) return toast(err.message);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
removeTab(tab);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function removeTab(tab) {
|
|
335
|
+
tab.ended ||= "已关闭";
|
|
336
|
+
clearTimeout(tab.retry);
|
|
337
|
+
tab.sock?.close();
|
|
338
|
+
tab.term.dispose();
|
|
339
|
+
tab.el.remove();
|
|
340
|
+
const i = T.tabs.indexOf(tab);
|
|
341
|
+
if (i >= 0) T.tabs.splice(i, 1);
|
|
342
|
+
if (T.active === tab) {
|
|
343
|
+
T.active = null;
|
|
344
|
+
const next = T.tabs[Math.min(i, T.tabs.length - 1)];
|
|
345
|
+
if (next) select(next);
|
|
346
|
+
}
|
|
347
|
+
// 底部面板里关掉最后一个:面板也收起来;远程控制页里留着「新建终端」
|
|
348
|
+
if (!T.tabs.length && T.mode === "dock") setMode("closed");
|
|
349
|
+
renderHead();
|
|
350
|
+
renderEmptyPane();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function ended(tab, reason) {
|
|
354
|
+
if (tab.ended) return;
|
|
355
|
+
tab.ended = reason || "已结束";
|
|
356
|
+
tab.state = "ended";
|
|
357
|
+
clearTimeout(tab.retry);
|
|
358
|
+
tab.term.write(`\r\n\x1b[2m[${tab.ended}]\x1b[0m\r\n`);
|
|
359
|
+
renderHead();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** 权限被收回:所有标签都关掉 */
|
|
363
|
+
function revoked() {
|
|
364
|
+
for (const tab of [...T.tabs]) {
|
|
365
|
+
tab.ended = "终端权限已被收回";
|
|
366
|
+
removeTab(tab);
|
|
367
|
+
}
|
|
368
|
+
setMode("closed");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ---------- 连接 ----------
|
|
372
|
+
|
|
373
|
+
function connect(tab) {
|
|
374
|
+
if (tab.ended || (tab.sock && tab.sock.readyState <= 1)) return;
|
|
375
|
+
clearTimeout(tab.retry);
|
|
376
|
+
tab.state = "connecting";
|
|
377
|
+
const proto = location.protocol === "https:" ? "wss" : "ws";
|
|
378
|
+
const sock = new WebSocket(`${proto}://${location.host}/ws/term?${new URLSearchParams({ id: tab.id, since: String(tab.total) })}`);
|
|
379
|
+
tab.sock = sock;
|
|
380
|
+
sock.onopen = () => {
|
|
381
|
+
tab.backoff = 500;
|
|
382
|
+
tab.size = "";
|
|
383
|
+
sendSize(tab);
|
|
384
|
+
};
|
|
385
|
+
sock.onmessage = (e) => {
|
|
386
|
+
const msg = JSON.parse(e.data);
|
|
387
|
+
if (msg.t === "o") {
|
|
388
|
+
tab.term.write(msg.d);
|
|
389
|
+
tab.total += msg.d.length;
|
|
390
|
+
} else if (msg.t === "a") {
|
|
391
|
+
if (msg.reset) tab.term.reset();
|
|
392
|
+
tab.term.write(msg.d);
|
|
393
|
+
tab.total = msg.total;
|
|
394
|
+
tab.state = "open";
|
|
395
|
+
renderHead();
|
|
396
|
+
} else if (msg.t === "x") {
|
|
397
|
+
ended(tab, msg.reason);
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
sock.onclose = async (e) => {
|
|
401
|
+
if (tab.sock !== sock || tab.ended) return;
|
|
402
|
+
tab.state = "connecting";
|
|
403
|
+
renderHead();
|
|
404
|
+
if (e.code === 4000) return ended(tab, e.reason || "已结束");
|
|
405
|
+
if (e.code === 4401) return showAuth();
|
|
406
|
+
if (e.code === 4403) return revoked();
|
|
407
|
+
// 握手被拒在浏览器里只表现为 1006:问一下服务端这个终端还在不在
|
|
408
|
+
if (e.code === 1006) {
|
|
409
|
+
try {
|
|
410
|
+
const data = await api("/api/terminals");
|
|
411
|
+
if (!data.allowed) return revoked();
|
|
412
|
+
if (!data.terminals.some((t) => t.id === tab.id)) return ended(tab, "已经结束了");
|
|
413
|
+
} catch {
|
|
414
|
+
// 连不上服务端:照常重试
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
tab.retry = setTimeout(() => connect(tab), tab.backoff);
|
|
418
|
+
tab.backoff = Math.min(tab.backoff * 2, 10_000);
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function send(tab, msg) {
|
|
423
|
+
if (tab.sock?.readyState === 1) tab.sock.send(JSON.stringify(msg));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function sendInput(tab, d) {
|
|
427
|
+
if (tab.ended) return;
|
|
428
|
+
// 手机上点了 Ctrl:下一个字母变成控制字符;点了 Alt:前面加个 Esc(终端里 Alt 就是这么发的)
|
|
429
|
+
if (T.ctrl && d.length === 1) {
|
|
430
|
+
const c = d.toUpperCase().charCodeAt(0);
|
|
431
|
+
if (c >= 64 && c <= 95) d = String.fromCharCode(c & 31);
|
|
432
|
+
setMod("ctrl", false);
|
|
433
|
+
}
|
|
434
|
+
if (T.alt && d.length >= 1) {
|
|
435
|
+
d = `\x1b${d}`;
|
|
436
|
+
setMod("alt", false);
|
|
437
|
+
}
|
|
438
|
+
send(tab, { t: "i", d });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function sendSize(tab) {
|
|
442
|
+
const size = `${tab.term.cols}x${tab.term.rows}`;
|
|
443
|
+
if (size === tab.size) return;
|
|
444
|
+
tab.size = size;
|
|
445
|
+
send(tab, { t: "r", c: tab.term.cols, r: tab.term.rows });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function fitActive() {
|
|
449
|
+
const tab = T.active;
|
|
450
|
+
if (!tab || T.mode === "closed" || tab.el.hidden) return;
|
|
451
|
+
// 面板藏着(远程控制页切走了、在看桌面)时量出来是 0:别把 shell 压成 2 列,等露出来再量
|
|
452
|
+
if (!ui.termPanes.clientWidth || !ui.termPanes.clientHeight) return;
|
|
453
|
+
try {
|
|
454
|
+
tab.fit.fit();
|
|
455
|
+
} catch {
|
|
456
|
+
// 面板还没显示出来,量不了
|
|
457
|
+
}
|
|
458
|
+
sendSize(tab);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// 手机切到后台再回来、网络恢复:马上重连,别等退避
|
|
462
|
+
const reconnectAll = () => {
|
|
463
|
+
for (const tab of T.tabs) if (tab.state === "connecting") connect(tab);
|
|
464
|
+
};
|
|
465
|
+
document.addEventListener("visibilitychange", () => document.visibilityState === "visible" && reconnectAll());
|
|
466
|
+
window.addEventListener("online", reconnectAll);
|
|
467
|
+
|
|
468
|
+
/** 服务端说终端列表变了(别的设备开了 / 关了、权限变了) */
|
|
469
|
+
export function syncTerminals() {
|
|
470
|
+
if (T.loaded) void loadTabs();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ---------- 手机上的快捷键 ----------
|
|
474
|
+
|
|
475
|
+
/** 方向键:程序开了「应用光标键」模式(vim、less…)时发 ESC O 那种 */
|
|
476
|
+
const arrow = (k) => () => (T.active?.term.modes.applicationCursorKeysMode ? `\x1bO${k}` : `\x1b[${k}`);
|
|
477
|
+
|
|
478
|
+
const KEYS = [
|
|
479
|
+
["Esc", "\x1b"],
|
|
480
|
+
["Tab", "\t"],
|
|
481
|
+
["Ctrl", "ctrl"],
|
|
482
|
+
["Alt", "alt"],
|
|
483
|
+
["←", arrow("D")],
|
|
484
|
+
["↑", arrow("A")],
|
|
485
|
+
["↓", arrow("B")],
|
|
486
|
+
["→", arrow("C")],
|
|
487
|
+
["Home", "\x1b[H"],
|
|
488
|
+
["End", "\x1b[F"],
|
|
489
|
+
["PgUp", "\x1b[5~"],
|
|
490
|
+
["PgDn", "\x1b[6~"],
|
|
491
|
+
["|", "|"],
|
|
492
|
+
["/", "/"],
|
|
493
|
+
["-", "-"],
|
|
494
|
+
["~", "~"],
|
|
495
|
+
["粘贴", "paste"],
|
|
496
|
+
];
|
|
497
|
+
|
|
498
|
+
function setMod(name, on) {
|
|
499
|
+
T[name] = on;
|
|
500
|
+
ui.termKeys?.querySelector(`[data-mod="${name}"]`)?.classList.toggle("on", on);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** 粘贴:要浏览器允许读剪贴板(HTTPS,第一次会问) */
|
|
504
|
+
async function pasteInto(tab) {
|
|
505
|
+
let text;
|
|
506
|
+
try {
|
|
507
|
+
text = await navigator.clipboard.readText();
|
|
508
|
+
} catch {
|
|
509
|
+
return toast("浏览器不让读剪贴板(要 HTTPS 并允许访问)");
|
|
510
|
+
}
|
|
511
|
+
// term.paste 会照顾「括号粘贴」模式,多行文字不会被一行行当命令执行
|
|
512
|
+
if (text) tab.term.paste(text);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function keyRow() {
|
|
516
|
+
return h(
|
|
517
|
+
"div",
|
|
518
|
+
{ class: "term-keys" },
|
|
519
|
+
KEYS.map(([label, key]) =>
|
|
520
|
+
h(
|
|
521
|
+
"button",
|
|
522
|
+
{
|
|
523
|
+
class: "term-key",
|
|
524
|
+
"data-mod": key === "ctrl" || key === "alt" ? key : undefined,
|
|
525
|
+
// 按下时别抢走焦点,不然软键盘会收起来
|
|
526
|
+
onpointerdown: (e) => e.preventDefault(),
|
|
527
|
+
onclick: () => {
|
|
528
|
+
const tab = T.active;
|
|
529
|
+
if (!tab) return;
|
|
530
|
+
if (key === "ctrl" || key === "alt") setMod(key, !T[key]);
|
|
531
|
+
else if (key === "paste") void pasteInto(tab);
|
|
532
|
+
else sendInput(tab, typeof key === "function" ? key() : key);
|
|
533
|
+
tab.term.focus();
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
label,
|
|
537
|
+
),
|
|
538
|
+
),
|
|
539
|
+
);
|
|
540
|
+
}
|
package/web/views/topbar.js
CHANGED
|
@@ -14,13 +14,14 @@ import { changeCwd, deleteConv, doAction, forkConversation, openModelPicker, ren
|
|
|
14
14
|
export function renderTopbar() {
|
|
15
15
|
const c = S.current ? S.convs.get(S.current) : null;
|
|
16
16
|
const tasks = S.view === "tasks";
|
|
17
|
+
const remote = S.view === "remote";
|
|
17
18
|
fill(ui.topbar,
|
|
18
19
|
h("button", { class: "icon-btn menu-btn", onclick: openSide, title: "会话列表" }, icon("menu")),
|
|
19
20
|
h(
|
|
20
21
|
"div",
|
|
21
22
|
{ class: "top-title" },
|
|
22
|
-
tasks
|
|
23
|
-
? h("span", { class: "conv-name" }, "定时任务")
|
|
23
|
+
tasks || remote
|
|
24
|
+
? h("span", { class: "conv-name" }, tasks ? "定时任务" : "远程控制")
|
|
24
25
|
: c
|
|
25
26
|
? [
|
|
26
27
|
// 聊天模式的对话:标题前面挂个聊天图标
|
|
@@ -181,7 +182,7 @@ export function baseName(p) {
|
|
|
181
182
|
return (p ?? "").split(/[\\/]/).filter(Boolean).pop() || p || "";
|
|
182
183
|
}
|
|
183
184
|
|
|
184
|
-
function currentCwd() {
|
|
185
|
+
export function currentCwd() {
|
|
185
186
|
const c = S.current ? S.convs.get(S.current) : null;
|
|
186
187
|
return c ? c.cwd : (S.draftCwd ?? S.workspaceRoot ?? "");
|
|
187
188
|
}
|