feat: file panes follow the machine going offline and back, reconnect a lost SSH host; zip and unzip over SSH where the host has them; phone keys only for terminals; version 0.2.0, README

This commit is contained in:
redtea committed 2026-10-15 21:58:09 +03:00
1 parent d92696226e
commit 01dc45ecad
8 files changed
+164 -29

No files matched your search

+17 -3
View File
@@ -12,9 +12,14 @@
``` ```
## Возможности ## Возможности
- **Терминал** - вкладки на нескольких машинах сразу, до четырёх терминалов в одной вкладке - **Одна страница** - Машины, WorkSpace (терминалы и файлы), Настройки; переходы без перезагрузки.
(перетаскиванием, с изменяемыми размерами), переименование вкладок. На телефоне - панель Вкладки WorkSpace живут, пока открыта вкладка браузера: уход на другие страницы их не
клавиш (Esc, Ctrl, Alt, стрелки и т.д.). закрывает, перезагрузка сбрасывает (браузер сначала спросит). «Порты», «Статус» и «Изменить»
машины открываются окном поверх текущей страницы - из списка машин или правым кликом
по заголовку вкладки или панели.
- **WorkSpace** - вкладки на нескольких машинах сразу, до четырёх панелей в одной вкладке -
терминал или файловый менеджер (перетаскиванием, с изменяемыми размерами), переименование
вкладок. На телефоне - панель клавиш (Esc, Ctrl, Alt, стрелки и т.д.).
- **SSH-терминал** - в меню «+»: агент открывает SSH-соединение к другому хосту (по паролю, - **SSH-терминал** - в меню «+»: агент открывает SSH-соединение к другому хосту (по паролю,
ключу - можно загрузить файл - или своими ключами из `~/.ssh`), шелл открывается во вкладке, ключу - можно загрузить файл - или своими ключами из `~/.ssh`), шелл открывается во вкладке,
а шаги подключения видны в статусе. Ключ хоста запоминается при первом входе а шаги подключения видны в статусе. Ключ хоста запоминается при первом входе
@@ -22,6 +27,15 @@
- **Файлы** - просмотр, редактор с подсветкой, hex-редактор, предпросмотр картинок, видео, PDF, - **Файлы** - просмотр, редактор с подсветкой, hex-редактор, предпросмотр картинок, видео, PDF,
баз SQLite с правкой (значение по клику, добавление и удаление строк, вкладка SQL; агент меняет базу через сам SQLite, так что работающая база не ломается); загрузка и скачивание до 512 МБ кусками по 4 МБ (проходит через Cloudflare); баз SQLite с правкой (значение по клику, добавление и удаление строк, вкладка SQL; агент меняет базу через сам SQLite, так что работающая база не ломается); загрузка и скачивание до 512 МБ кусками по 4 МБ (проходит через Cloudflare);
свойства (chmod/chown), zip/unzip. свойства (chmod/chown), zip/unzip.
- **Файлы по SSH** - в меню «+» › «Файловый менеджер» › «SSH-хост…»: машина подключается к хосту
и работает с файлами командами по SSH, SFTP не нужен. Есть всё, кроме редактора SQLite;
zip и распаковка - если на хосте установлены `zip` и `unzip`. Оборвалось подключение -
панель предложит переподключиться.
- **Перетаскивание файлов** - строку (или все отмеченные) в другую файловую панель или в папку
в ней: окно «Переместить / Копировать» у места броска, между машинами и SSH-хостами - с
прогрессом. В терминал той же машины или того же SSH-хоста - вставляется путь в кавычках,
в терминал другой - нельзя, с подсказкой почему. Задержать над заголовком вкладки - она
откроется. Только на компьютере.
- **Перемещение и копирование** - внутри машины или, кнопкой «сеть» в том же окне, **на другую - **Перемещение и копирование** - внутри машины или, кнопкой «сеть» в том же окне, **на другую
машину или на SSH-хост**: папка уходит потоком tar без промежуточных файлов, с прогрессом, машину или на SSH-хост**: папка уходит потоком tar без промежуточных файлов, с прогрессом,
скоростью и отменой. «Переместить» удаляет исходник после успешной отправки. Правила для скоростью и отменой. «Переместить» удаляет исходник после успешной отправки. Правила для
+1 -1
View File
@@ -1 +1 @@
0.1.2 0.2.0
+56 -2
View File
@@ -115,7 +115,8 @@ func (a *Agent) sshfsOpen(ctx context.Context, t proto.SSHTarget) (any, error) {
return nil, err return nil, err
} }
c := &sshfsConn{client: client, target: t, last: time.Now()} c := &sshfsConn{client: client, target: t, last: time.Now()}
out, _ := sshRun(client, "stat -c %F / >/dev/null 2>&1 && echo GNU; stat -f %HT / >/dev/null 2>&1 && echo BSD; echo \"$HOME\"") out, _ := sshRun(client, "stat -c %F / >/dev/null 2>&1 && echo GNU; stat -f %HT / >/dev/null 2>&1 && echo BSD; "+
"command -v zip >/dev/null 2>&1 && echo ZIP; command -v unzip >/dev/null 2>&1 && echo UNZIP; echo \"$HOME\"")
lines := strings.Split(strings.TrimSpace(out), "\n") lines := strings.Split(strings.TrimSpace(out), "\n")
switch { switch {
case strings.Contains(out, "GNU"): case strings.Contains(out, "GNU"):
@@ -132,7 +133,18 @@ func (a *Agent) sshfsOpen(ctx context.Context, t proto.SSHTarget) (any, error) {
id := randHex(12) id := randHex(12)
sshfsConns.Store(id, c) sshfsConns.Store(id, c)
sshfsJanitor.Do(func() { go sshfsSweep() }) sshfsJanitor.Do(func() { go sshfsSweep() })
return map[string]string{"id": id, "home": c.home}, nil // zip and unzip, where the host has them, for the pane's buttons.
return map[string]any{"id": id, "home": c.home,
"zip": hasLine(lines, "ZIP"), "unzip": hasLine(lines, "UNZIP")}, nil
}
func hasLine(lines []string, w string) bool {
for _, l := range lines {
if strings.TrimSpace(l) == w {
return true
}
}
return false
} }
// sshfsSweep closes connections nobody used for sshfsIdle. // sshfsSweep closes connections nobody used for sshfsIdle.
@@ -318,6 +330,20 @@ func (a *Agent) sshfsOp(c *sshfsConn, op string, raw json.RawMessage) (any, erro
var p proto.FSCopyArgs var p proto.FSCopyArgs
_ = json.Unmarshal(raw, &p) _ = json.Unmarshal(raw, &p)
return nil, a.sshfsCopy(c, p) return nil, a.sshfsCopy(c, p)
case "zip":
var p proto.FSZipArgs
_ = json.Unmarshal(raw, &p)
cmd, err := zipCommand(p.Paths, p.Dest)
if err != nil {
return nil, err
}
_, err = a.sshfsRun(c, cmd, nil)
return map[string]string{"dest": p.Dest}, err
case "unzip":
var p proto.FSUnzipArgs
_ = json.Unmarshal(raw, &p)
_, err := a.sshfsRun(c, unzipCommand(p.Path, p.Dest), nil)
return map[string]string{"dest": p.Dest}, err
case "stat": case "stat":
var p proto.FSPathArgs var p proto.FSPathArgs
_ = json.Unmarshal(raw, &p) _ = json.Unmarshal(raw, &p)
@@ -349,6 +375,34 @@ func (a *Agent) sshfsOp(c *sshfsConn, op string, raw json.RawMessage) (any, erro
return nil, errors.New("неизвестная операция") return nil, errors.New("неизвестная операция")
} }
// zipCommand packs paths - items of one folder - into the archive dest, from
// that folder, under their own names. Paths are absolute and names start
// with "./", so none reads as an option.
func zipCommand(paths []string, dest string) (string, error) {
if len(paths) == 0 {
return "", errors.New("не выбраны файлы")
}
dir := path.Dir(path.Clean(paths[0]))
names := make([]string, len(paths))
for i, p := range paths {
p = path.Clean(p)
if path.Dir(p) != dir {
return "", errors.New("в архив - только из одной папки")
}
names[i] = shq("./" + path.Base(p))
}
return "cd -- " + shq(dir) + " && zip -r -q -y " + shq(path.Clean(dest)) + " " + strings.Join(names, " "), nil
}
// unzipCommand unpacks the archive p into dest (its own folder if empty),
// replacing what is there, as on a machine.
func unzipCommand(p, dest string) string {
if dest == "" {
dest = path.Dir(path.Clean(p))
}
return "unzip -o -q " + shq(path.Clean(p)) + " -d " + shq(path.Clean(dest))
}
// writeCommand writes stdin to p through a file beside it, so a broken // writeCommand writes stdin to p through a file beside it, so a broken
// transfer leaves the old file as it was. // transfer leaves the old file as it was.
func writeCommand(p string) string { func writeCommand(p string) string {
+21
View File
@@ -106,3 +106,24 @@ func TestRenameTarDropsOtherEntries(t *testing.T) {
t.Fatalf("entries: %v", got) t.Fatalf("entries: %v", got)
} }
} }
// zip on an SSH host: from the items' folder, by their own names, quoted.
func TestZipCommand(t *testing.T) {
cmd, err := zipCommand([]string{"/srv/a b", "/srv/it's"}, "/tmp/x.zip")
if err != nil {
t.Fatal(err)
}
want := `cd -- '/srv' && zip -r -q -y '/tmp/x.zip' './a b' './it'\''s'`
if cmd != want {
t.Fatalf("got %s\nwant %s", cmd, want)
}
if _, err := zipCommand([]string{"/a/x", "/b/y"}, "/t.zip"); err == nil {
t.Fatal("items of two folders: want an error")
}
if _, err := zipCommand(nil, "/t.zip"); err == nil {
t.Fatal("no items: want an error")
}
if got := unzipCommand("/srv/a.zip", ""); got != `unzip -o -q '/srv/a.zip' -d '/srv'` {
t.Fatalf("unzip: %s", got)
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ func (s *Server) handleSSHFSOpen(w http.ResponseWriter, r *http.Request) {
} }
// sshfsChanges are the operations that change files, for the audit log. // sshfsChanges are the operations that change files, for the audit log.
var sshfsChanges = map[string]bool{"write": true, "mkdir": true, "delete": true, "deltree": true, "move": true, "copy": true, "chmod": true} var sshfsChanges = map[string]bool{"write": true, "mkdir": true, "delete": true, "deltree": true, "move": true, "copy": true, "chmod": true, "zip": true, "unzip": true}
// POST /api/panel/agents/{id}/sshfs/{conn}/{op} - an operation on the host; // POST /api/panel/agents/{id}/sshfs/{conn}/{op} - an operation on the host;
// the body is in the shape of the machine's own fs.{op}. "close" drops the // the body is in the shape of the machine's own fs.{op}. "close" drops the
+2 -1
View File
@@ -420,7 +420,8 @@ body.touch.multi .tabs { padding-top:6px; max-height:38px; }
body.touch .tab { height:28px; font-size:12px; padding:0 2px 0 8px; gap:6px; } body.touch .tab { height:28px; font-size:12px; padding:0 2px 0 8px; gap:6px; }
body.touch .tab-add { width:30px; height:28px; } body.touch .tab-add { width:30px; height:28px; }
body.touch .tabview { inset:4px; } body.touch .tabview { inset:4px; }
body.touch .keys { body.touch:not(.term-active) .keys { display:none; }
body.touch.term-active .keys {
display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none; display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none;
padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border); padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border);
} }
+46 -12
View File
@@ -44,7 +44,7 @@
// A machine's files: the panel's file API of that machine. // A machine's files: the panel's file API of that machine.
class MachineSource { class MachineSource {
constructor(agent) { this.agent = agent; this.caps = {zip: true, sqlite: true}; } constructor(agent) { this.agent = agent; this.caps = {zip: true, unzip: true, sqlite: true}; }
url(what, path) { return '/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/fs/' + what + '?path=' + encodeURIComponent(path); } url(what, path) { return '/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/fs/' + what + '?path=' + encodeURIComponent(path); }
op(name, body) { op(name, body) {
return fetch('/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/fs/' + name, { return fetch('/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/fs/' + name, {
@@ -57,7 +57,8 @@
// An SSH host's files, through the machine that holds the connection. // An SSH host's files, through the machine that holds the connection.
class SSHSource { class SSHSource {
constructor(agent, conn, home) { this.agent = agent; this.conn = conn; this.homeDir = home || '/'; this.caps = {zip: false, sqlite: false}; } // caps: zip and unzip where the host has the commands.
constructor(agent, conn, home, caps) { this.agent = agent; this.conn = conn; this.homeDir = home || '/'; this.caps = Object.assign({zip: false, unzip: false, sqlite: false}, caps); }
base() { return '/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/sshfs/' + encodeURIComponent(this.conn) + '/'; } base() { return '/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/sshfs/' + encodeURIComponent(this.conn) + '/'; }
url(what, path) { return this.base() + what + '?path=' + encodeURIComponent(path); } url(what, path) { return this.base() + what + '?path=' + encodeURIComponent(path); }
op(name, body) { op(name, body) {
@@ -123,7 +124,7 @@
'</tr></thead><tbody class="fp-list"></tbody></table></div>' + '</tr></thead><tbody class="fp-list"></tbody></table></div>' +
'<p class="muted fp-msg" hidden></p>' + '<p class="muted fp-msg" hidden></p>' +
'</div>'; '</div>';
if (!this.src.caps.zip) this.q('.fp-zip').hidden = true; this.q('.fp-zip').hidden = !this.src.caps.zip;
this.bind(); this.bind();
// Narrow panes (a split tab) fold like the phone layout. // Narrow panes (a split tab) fold like the phone layout.
// A mid-width pane gives the time's room to the names. // A mid-width pane gives the time's room to the names.
@@ -167,19 +168,41 @@
this.setBusy(true); this.setBusy(true);
try { return await this.src.op(op, body); } finally { this.setBusy(false); } try { return await this.src.op(op, body); } finally { this.setBusy(false); }
} }
message(text) { // message: a line over the list - why it is empty - with a button to
// do something about it.
message(text, label, fn) {
const m = this.q('.fp-msg'); const m = this.q('.fp-msg');
m.hidden = !text; m.hidden = !text;
m.textContent = text || ''; m.textContent = text || '';
if (label) {
const b = document.createElement('button');
b.type = 'button'; b.className = 'btn ghost small'; b.textContent = label;
b.onclick = fn;
m.append(' ', b);
}
}
// trouble: the machine offline, the access gone or the SSH connection
// lost - the pane says so and offers the way back.
trouble(err) {
// A machine that just went away first times out, then is offline.
this.offline = /офлайн|таймаут ожидания агента/.test(err);
if (this.offline) this.message((/офлайн/.test(err) ? 'Машина офлайн' : 'Машина не отвечает') + '. Панель обновится сама, когда машина вернётся.', 'Повторить', () => this.reload());
else if (/нет доступа/.test(err)) this.message('Нет доступа к машине.');
else if (this.ssh && /подключение (к хосту потеряно|закрыто)/.test(err)) this.message(err, 'Переподключиться', () => Workspace.openSSH(this.agent, 'files', this));
else return false;
this.entries = []; this.selected.clear(); this.render();
this.setState('err', err);
return true;
} }
async start() { async start() {
// Start in the home folder of the machine's user (or the SSH home); // Start in the home folder of the machine's user (or the SSH home);
// a listing that fails there falls back to the root. // a listing that fails there falls back to the root.
const r = await this.api('list', {path: this.src.home()}); const r = await this.api('list', {path: this.src.home()});
if (r.ok) this.show(r.data); else this.go('/'); if (r.ok) this.show(r.data); else if (!this.trouble(r.error || '')) this.go('/');
} }
show(d) { show(d) {
this.offline = false; this.loaded = true;
this.cwd = d.path; this.entries = d.entries || []; this.selected.clear(); this.cwd = d.path; this.entries = d.entries || []; this.selected.clear();
this.q('.fp-path').value = this.cwd; this.q('.fp-path').value = this.cwd;
this.message(''); this.message('');
@@ -189,15 +212,15 @@
async go(path) { async go(path) {
const r = await this.api('list', {path}); const r = await this.api('list', {path});
if (!r.ok) { if (!r.ok) {
toast(r.error || 'ошибка', true); if (!this.trouble(r.error || '')) toast(r.error || 'ошибка', true);
if (/офлайн|нет доступа/.test(r.error || '')) { this.message(r.error); this.setState('err', r.error); }
this.q('.fp-path').value = this.cwd; this.q('.fp-path').value = this.cwd;
return false; return false;
} }
this.show(r.data); this.show(r.data);
return true; return true;
} }
reload() { return this.go(this.cwd); } // Never listed yet (offline from the start): from the home folder.
reload() { return this.loaded ? this.go(this.cwd) : this.start(); }
// Sorting: by name, size or time, folders always ahead of files. Kept in // Sorting: by name, size or time, folders always ahead of files. Kept in
// this browser for each folder of each place. // this browser for each folder of each place.
@@ -230,7 +253,7 @@
const tr = document.createElement('tr'); const tr = document.createElement('tr');
if (e.is_dir) tr.dataset.dir = e.name; if (e.is_dir) tr.dataset.dir = e.name;
tr.dataset.name = e.name; tr.dataset.name = e.name;
const isZip = this.src.caps.zip && !e.is_dir && /\.zip$/i.test(e.name); const isZip = this.src.caps.unzip && !e.is_dir && /\.zip$/i.test(e.name);
tr.innerHTML = tr.innerHTML =
'<td><input type="checkbox" class="ck" aria-label="Выбрать"></td>' + '<td><input type="checkbox" class="ck" aria-label="Выбрать"></td>' +
'<td class="nm">' + fileIcon(e) + esc(e.name) + '</td>' + '<td class="nm">' + fileIcon(e) + esc(e.name) + '</td>' +
@@ -380,16 +403,27 @@
} }
} }
// Panes of a machine gone offline look every 15 s whether it is back.
setInterval(() => {
const off = Workspace.panes().filter(p => p.offline);
if (!off.length || document.hidden) return;
fetch('/api/panel/agents/presence', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(all => {
off.forEach(p => { if (all.some(a => a.id === p.agent.id && a.online)) p.reload(); });
}).catch(() => {});
}, 15000);
window.FilePane = FilePane; window.FilePane = FilePane;
window.MachineSource = MachineSource; window.MachineSource = MachineSource;
Workspace.paneTypes.files = agent => new FilePane({kind: 'agent', agent}); Workspace.paneTypes.files = agent => new FilePane({kind: 'agent', agent});
Workspace.paneTypes.sshfiles = (agent, o) => new FilePane({kind: 'ssh', agent, ssh: o.ssh, src: new SSHSource(agent, o.conn, o.home)}); Workspace.paneTypes.sshfiles = (agent, o) => new FilePane({kind: 'ssh', agent, ssh: o.ssh, src: new SSHSource(agent, o.conn, o.home, o.caps)});
// sshFiles connects agent to the SSH host t, then opens its files in a // sshFiles connects agent to the SSH host t, then opens its files in a
// pane; the password or key goes to the machine once and is not kept here. // pane; the password or key goes to the machine once and is not kept here.
Workspace.sshFiles = (agent, t) => fetch('/api/panel/agents/' + encodeURIComponent(agent.id) + '/sshfs/open', { Workspace.sshFiles = (agent, t, into) => fetch('/api/panel/agents/' + encodeURIComponent(agent.id) + '/sshfs/open', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ssh: t}), credentials: 'same-origin' method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ssh: t}), credentials: 'same-origin'
}).then(r => r.status === 404 ? {ok: false, error: 'нет доступа к машине'} : r.json()).then(r => { }).then(r => r.status === 404 ? {ok: false, error: 'нет доступа к машине'} : r.json()).then(r => {
if (r.ok) Workspace.open('sshfiles', agent, {conn: r.data.id, home: r.data.home, ssh: {host: t.host, port: t.port, user: t.user}}); const caps = r.ok ? {zip: !!r.data.zip, unzip: !!r.data.unzip} : null;
if (r.ok && into) { into.src.conn = r.data.id; Object.assign(into.src.caps, caps); into.q('.fp-zip').hidden = !caps.zip; into.reload(); }
else if (r.ok) Workspace.open('sshfiles', agent, {conn: r.data.id, home: r.data.home, caps, ssh: {host: t.host, port: t.port, user: t.user}});
return r; return r;
}).catch(() => ({ok: false, error: 'ошибка сети'})); }).catch(() => ({ok: false, error: 'ошибка сети'}));
})(); })();
+20 -9
View File
@@ -111,6 +111,12 @@
} }
function setActivePane(s) { function setActivePane(s) {
activePane = s; activePane = s;
// The phone's extra keys are a terminal's: a file pane goes without.
const term = s.kind === 'term' || s.kind === 'ssh';
if (touch && document.body.classList.contains('term-active') !== term) {
document.body.classList.toggle('term-active', term);
setTimeout(() => active && layout(active, false), 0);
}
document.querySelectorAll('.pane.active').forEach(p => p.classList.toggle('active', p === s.el)); document.querySelectorAll('.pane.active').forEach(p => p.classList.toggle('active', p === s.el));
s.el.classList.add('active'); s.el.classList.add('active');
showStatus(); showStatus();
@@ -133,7 +139,8 @@
function placeFloat() { function placeFloat() {
const p = tabs.length === 1 ? tabs[0].panes.find(s => s.el.classList.contains('under-plus')) : null; const p = tabs.length === 1 ? tabs[0].panes.find(s => s.el.classList.contains('under-plus')) : null;
const bars = p && /files$/.test(p.kind) ? p.el.querySelectorAll('.fm-bar') : []; const bars = p && /files$/.test(p.kind) ? p.el.querySelectorAll('.fm-bar') : [];
const on = bars.length > 1 && bars[0].offsetParent !== null; // A bar folded in two (a phone) keeps the corner "+".
const on = bars.length > 1 && bars[0].offsetParent !== null && bars[0].offsetHeight < 48;
floatAdd.classList.toggle('in-files', on); floatAdd.classList.toggle('in-files', on);
if (!on) { floatAdd.style.top = floatAdd.style.right = ''; return; } if (!on) { floatAdd.style.top = floatAdd.style.right = ''; return; }
const a = area.getBoundingClientRect(), top = bars[0].getBoundingClientRect(), row = bars[1].getBoundingClientRect(); const a = area.getBoundingClientRect(), top = bars[0].getBoundingClientRect(), row = bars[1].getBoundingClientRect();
@@ -160,7 +167,7 @@
function closeTab(t) { function closeTab(t) {
const i = tabs.indexOf(t); const i = tabs.indexOf(t);
if (i < 0) return; if (i < 0) return;
if (t.panes.length > 1 && !confirm('Закрыть вкладку с ' + t.panes.length + ' терминалами?')) return; if (t.panes.length > 1 && !confirm('Закрыть вкладку с ' + t.panes.length + ' панелями?')) return;
tabs.splice(i, 1); tabs.splice(i, 1);
t.panes.forEach(s => s.close()); t.panes.forEach(s => s.close());
t.el.classList.add('leave'); t.el.classList.add('leave');
@@ -170,7 +177,7 @@
active = null; active = null;
activate(tabs[Math.min(i, tabs.length - 1)]); activate(tabs[Math.min(i, tabs.length - 1)]);
} }
if (!tabs.length) { activePane = null; showStatus(); $('empty').hidden = false; } if (!tabs.length) { activePane = null; showStatus(); $('empty').hidden = false; document.body.classList.remove('term-active'); }
updateMode(); updateMode();
} }
function rename(t) { function rename(t) {
@@ -525,12 +532,14 @@
// ---------- the SSH dialog ---------- // ---------- the SSH dialog ----------
const KEYI = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>'; const KEYI = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>';
let sshDlg = null, sshMode = 'term'; let sshInto = null, sshDlg = null, sshMode = 'term';
// openSSH asks where to connect; mode says what opens there: a terminal // openSSH asks where to connect; mode says what opens there: a terminal
// ('term') or a file manager ('files', through Workspace.sshFiles). // ('term') or a file manager ('files', through Workspace.sshFiles).
function openSSH(here, mode) { // into: a file pane whose SSH connection is lost - the dialog comes with
// its host, port and user, and connects that pane again.
function openSSH(here, mode, into) {
if (!sshDlg) sshDlg = makeSSHDialog(); if (!sshDlg) sshDlg = makeSSHDialog();
sshDlg.show(here, mode || 'term'); sshDlg.show(here, mode || 'term', into);
} }
function makeSSHDialog() { function makeSSHDialog() {
const d = document.createElement('dialog'); const d = document.createElement('dialog');
@@ -605,7 +614,7 @@
const btn = f.querySelector('button[type=submit]'); const btn = f.querySelector('button[type=submit]');
btn.disabled = true; btn.disabled = true;
msg.textContent = 'подключение…'; msg.textContent = 'подключение…';
window.Workspace.sshFiles(a, t).then(r => { window.Workspace.sshFiles(a, t, sshInto).then(r => {
btn.disabled = false; btn.disabled = false;
if (!r.ok) { msg.textContent = r.error || 'не подключилось'; return; } if (!r.ok) { msg.textContent = r.error || 'не подключилось'; return; }
msg.textContent = ''; msg.textContent = '';
@@ -616,8 +625,9 @@
close(() => openTerminal(a, t)); close(() => openTerminal(a, t));
}; };
return { return {
show(here, mode) { show(here, mode, into) {
sshMode = mode; sshMode = mode;
sshInto = into || null;
d.querySelector('.dlg-h strong').textContent = mode === 'files' ? 'Файлы по SSH' : 'SSH-подключение'; d.querySelector('.dlg-h strong').textContent = mode === 'files' ? 'Файлы по SSH' : 'SSH-подключение';
msg.textContent = ''; msg.textContent = '';
let last = {}; let last = {};
@@ -639,6 +649,7 @@
if (here && !agents.some(a => a.id === here.id)) agents.unshift(here); if (here && !agents.some(a => a.id === here.id)) agents.unshift(here);
fill(); fill();
}).catch(() => {}); }).catch(() => {});
if (into) last = { agent: here.id, host: into.ssh.host, port: into.ssh.port, user: into.ssh.user, auth: last.auth };
if (last.host) { f.host.value = last.host; f.port.value = last.port || 22; f.user.value = last.user || ''; } if (last.host) { f.host.value = last.host; f.port.value = last.port || 22; f.user.value = last.user || ''; }
setAuth(last.auth === 'key' || last.auth === 'agent' ? last.auth : 'password'); setAuth(last.auth === 'key' || last.auth === 'agent' ? last.auth : 'password');
d.showModal(); d.showModal();
@@ -692,7 +703,7 @@
panes: () => tabs.flatMap(t => t.panes), panes: () => tabs.flatMap(t => t.panes),
activePane: () => activePane, activePane: () => activePane,
renderPane: p => { const t = tabOf(p); if (t) renderTab(t); if (p === activePane) showStatus(); }, renderPane: p => { const t = tabOf(p); if (t) renderTab(t); if (p === activePane) showStatus(); },
setActivePane, tabOf, renderTab, showStatus, closePane, startDrag, setActivePane, tabOf, renderTab, showStatus, closePane, startDrag, openSSH,
// paneAt, tabAt: the pane of the shown tab, the tab title at a point. // paneAt, tabAt: the pane of the shown tab, the tab title at a point.
paneAt: (x, y) => active && active.panes.find(p => in_(p.el, x, y)), paneAt: (x, y) => active && active.panes.find(p => in_(p.el, x, y)),
tabAt: (x, y) => document.body.classList.contains('multi') ? tabs.find(t => in_(t.el, x, y)) : null tabAt: (x, y) => document.body.classList.contains('multi') ? tabs.find(t => in_(t.el, x, y)) : null