From 434900a16808c934ce42868d21447d2d0aee76ec Mon Sep 17 00:00:00 2001 From: iRedTea Date: Fri, 16 Oct 2026 09:31:21 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20long=20names=20in=20questions=20cut=20w?= =?UTF-8?q?ith=20=E2=80=A6=20and=20the=20window=20as=20wide=20as=20its=20w?= =?UTF-8?q?ords;=20SSH=20dialog=20hides=20=C2=AB=D0=92=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=C2=BB=20for=20a=20saved=20host=20and=20says=20=C2=AB=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=E2=80=A6=C2=BB=20quietly;=20zip=20and=20unzip=20on=20SSH=20hos?= =?UTF-8?q?ts=20without=20them=20through=20Python's=20zipfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/agent/sshfs.go | 13 +++++++++---- internal/agent/sshfs_test.go | 5 +++-- internal/server/web/static/app.css | 4 +++- internal/server/web/static/ui.js | 12 +++++++++++- internal/server/web/static/workspace.js | 26 +++++++++++++------------ 5 files changed, 40 insertions(+), 20 deletions(-) diff --git a/internal/agent/sshfs.go b/internal/agent/sshfs.go index 8848501..6963987 100644 --- a/internal/agent/sshfs.go +++ b/internal/agent/sshfs.go @@ -139,7 +139,8 @@ func (a *Agent) sshfsOpen(ctx context.Context, t proto.SSHTarget) (any, error) { } 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; "+ - "command -v zip >/dev/null 2>&1 && echo ZIP; command -v unzip >/dev/null 2>&1 && echo UNZIP; echo \"$HOME\"") + "command -v zip >/dev/null 2>&1 && echo ZIP; command -v unzip >/dev/null 2>&1 && echo UNZIP; "+ + "command -v python3 >/dev/null 2>&1 && echo PY; echo \"$HOME\"") lines := strings.Split(strings.TrimSpace(out), "\n") switch { case strings.Contains(out, "GNU"): @@ -158,7 +159,7 @@ func (a *Agent) sshfsOpen(ctx context.Context, t proto.SSHTarget) (any, error) { sshfsJanitor.Do(func() { go sshfsSweep() }) // 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 + "zip": hasLine(lines, "ZIP") || hasLine(lines, "PY"), "unzip": hasLine(lines, "UNZIP") || hasLine(lines, "PY")}, nil } func hasLine(lines []string, w string) bool { @@ -410,7 +411,10 @@ func zipCommand(paths []string, dest string) (string, error) { } names[i] = shq("./" + path.Base(p)) } - return "cd -- " + shq(dir) + " && zip -r -q -y " + shq(path.Clean(dest)) + " " + strings.Join(names, " "), nil + // zip where the host has it, else Python's zipfile, which most have. + items := strings.Join(names, " ") + return "cd -- " + shq(dir) + " && if command -v zip >/dev/null 2>&1; then zip -r -q -y " + shq(path.Clean(dest)) + " " + items + "; " + + "else python3 -m zipfile -c " + shq(path.Clean(dest)) + " " + items + "; fi", nil } // unzipCommand unpacks the archive p into dest (its own folder if empty), @@ -419,7 +423,8 @@ 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)) + return "if command -v unzip >/dev/null 2>&1; then unzip -o -q " + shq(path.Clean(p)) + " -d " + shq(path.Clean(dest)) + "; " + + "else python3 -m zipfile -e " + shq(path.Clean(p)) + " " + shq(path.Clean(dest)) + "; fi" } // writeCommand writes stdin to p through a file beside it, so a broken diff --git a/internal/agent/sshfs_test.go b/internal/agent/sshfs_test.go index 281d0f6..a6b2ac1 100644 --- a/internal/agent/sshfs_test.go +++ b/internal/agent/sshfs_test.go @@ -114,7 +114,8 @@ func TestZipCommand(t *testing.T) { if err != nil { t.Fatal(err) } - want := `cd -- '/srv' && zip -r -q -y '/tmp/x.zip' './a b' './it'\''s'` + want := `cd -- '/srv' && if command -v zip >/dev/null 2>&1; then zip -r -q -y '/tmp/x.zip' './a b' './it'\''s'; ` + + `else python3 -m zipfile -c '/tmp/x.zip' './a b' './it'\''s'; fi` if cmd != want { t.Fatalf("got %s\nwant %s", cmd, want) } @@ -124,7 +125,7 @@ func TestZipCommand(t *testing.T) { 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'` { + if got := unzipCommand("/srv/a.zip", ""); got != `if command -v unzip >/dev/null 2>&1; then unzip -o -q '/srv/a.zip' -d '/srv'; else python3 -m zipfile -e '/srv/a.zip' '/srv'; fi` { t.Fatalf("unzip: %s", got) } } diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index c5b640b..9901f30 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -303,6 +303,7 @@ dialog.sshdlg { max-width:460px; } .sshdlg .seg button:hover { color:var(--text); } .sshdlg .seg button.on { background:#12202f; color:var(--text); box-shadow:inset 0 0 0 1px #1f3a57; } .sshdlg .sshmsg { margin:0; min-height:18px; font-size:13px; color:var(--danger); } +.sshdlg .sshmsg.going { color:var(--muted); } .sshdlg .row.end { display:flex; justify-content:flex-end; gap:8px; } @media (max-width:520px) { .sshdlg .seg button { font-size:12px; padding:6px 4px; } } /* The page is the terminal: bar, tabs, then the terminals to the bottom. */ @@ -859,9 +860,10 @@ body.touch.term-active .ai-fab { bottom:104px; } } .ai-send { width:40px; height:40px; padding:0; flex:none; display:inline-flex; align-items:center; justify-content:center; border-radius:8px; } /* The panel's own confirm and prompt. */ -dialog.uidlg { max-width:420px; width:92vw; } +dialog.uidlg { width:auto; min-width:min(320px, 92vw); max-width:min(560px, 92vw); } .uidlg form { padding:18px; display:grid; gap:14px; } .uidlg .ui-text { margin:0; white-space:pre-wrap; line-height:1.45; } +.uidlg .ui-long { display:inline-block; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; vertical-align:bottom; } .uidlg .ui-input[hidden] { display:none; } .sshdlg .chk { display:flex; align-items:center; gap:8px; font-size:13px; color:var(--muted); } .sshdlg .chk input { width:auto; } diff --git a/internal/server/web/static/ui.js b/internal/server/web/static/ui.js index 49b2b70..47fb364 100644 --- a/internal/server/web/static/ui.js +++ b/internal/server/web/static/ui.js @@ -16,7 +16,17 @@ if (!dlg) make(); if (dlg.open) dlg.close(''); const input = dlg.querySelector('.ui-input'), yes = dlg.querySelector('.ui-yes'); - dlg.querySelector('.ui-text').textContent = text; + // Long words (a file name) stay on one line, cut with "…"; the window + // is as wide as its words, up to the screen's room. + const t = dlg.querySelector('.ui-text'); + t.textContent = ''; + String(text).split(/(\s+)/).forEach(w => { + if (w.length > 28 && !/\s/.test(w)) { + const s = document.createElement('span'); + s.className = 'ui-long'; s.textContent = w; s.title = w; + t.appendChild(s); + } else t.appendChild(document.createTextNode(w)); + }); input.hidden = !o.input; input.value = o.value || ''; yes.textContent = o.ok || 'OK'; diff --git a/internal/server/web/static/workspace.js b/internal/server/web/static/workspace.js index 9228acb..3129952 100644 --- a/internal/server/web/static/workspace.js +++ b/internal/server/web/static/workspace.js @@ -573,7 +573,7 @@ '
' + '
' + '' + - '
Вход
' + + '
Вход
' + '
' + '
' + ''; document.body.appendChild(d); const f = d.querySelector('form'), msg = d.querySelector('.sshmsg'); + // say: a line under the form - red for what went wrong, quiet for what goes on. + const say = (t, going) => { msg.textContent = t; msg.classList.toggle('going', !!going); }; let auth = 'password'; const setAuth = a => { auth = a; @@ -601,8 +603,8 @@ file.onchange = () => { const fl = file.files[0]; if (!fl) return; - if (fl.size > 32768) { msg.textContent = 'файл великоват для ключа'; return; } - fl.text().then(t => { f.key.value = t.trim() + '\n'; d.querySelector('.keyname').textContent = fl.name; msg.textContent = ''; }); + if (fl.size > 32768) { say('файл великоват для ключа'); return; } + fl.text().then(t => { f.key.value = t.trim() + '\n'; d.querySelector('.keyname').textContent = fl.name; say(''); }); file.value = ''; }; // then runs once the dialog is shut and focus is back where it was, so @@ -618,7 +620,7 @@ const useSaved = () => { const h = saved.find(x => String(x.id) === f.saved.value); d.querySelector('[data-delsaved]').hidden = !h; - d.querySelectorAll('.seg, .auth, .save-row').forEach(el => { el.style.display = h ? 'none' : ''; }); + d.querySelectorAll('.auth-row, .auth, .save-row').forEach(el => { el.style.display = h ? 'none' : ''; }); if (!h) { setAuth(auth); return; } f.host.value = h.host; f.port.value = h.port; f.user.value = h.user; }; @@ -642,15 +644,15 @@ f.onsubmit = async ev => { ev.preventDefault(); const a = agents.find(x => x.id === f.agent.value); - if (!a) { msg.textContent = 'выберите машину'; return; } + if (!a) { say('выберите машину'); return; } const t = { host: f.host.value.trim(), port: Number(f.port.value) || 22, user: f.user.value.trim() }; if (f.saved.value) { t.saved = Number(f.saved.value); } else if (auth === 'password') { - if (!f.password.value) { msg.textContent = 'введите пароль'; f.password.focus(); return; } + if (!f.password.value) { say('введите пароль'); f.password.focus(); return; } t.password = f.password.value; } else if (auth === 'key') { - if (!/PRIVATE KEY/.test(f.key.value)) { msg.textContent = 'вставьте закрытый ключ или загрузите файл'; return; } + if (!/PRIVATE KEY/.test(f.key.value)) { say('вставьте закрытый ключ или загрузите файл'); return; } t.key = f.key.value; if (f.passphrase.value) t.passphrase = f.passphrase.value; } @@ -658,7 +660,7 @@ // Saved first, then used by its id like any saved host. const r = await fetch('/api/panel/ssh/hosts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign({ auth: auth }, t)) }).then(x => x.json()).catch(() => ({ ok: false, error: 'ошибка сети' })); - if (!r.ok) { msg.textContent = r.error || 'не сохранилось'; return; } + if (!r.ok) { say(r.error || 'не сохранилось'); return; } f.save.checked = false; loadSaved(); } @@ -669,11 +671,11 @@ // The machine connects first; the pane opens once it is in. const btn = f.querySelector('button[type=submit]'); btn.disabled = true; - msg.textContent = 'подключение…'; + say('подключение…', true); window.Workspace.sshFiles(a, t, sshInto).then(r => { btn.disabled = false; - if (!r.ok) { msg.textContent = r.error || 'не подключилось'; return; } - msg.textContent = ''; + if (!r.ok) { say(r.error || 'не подключилось'); return; } + say(''); close(); }); return; @@ -685,7 +687,7 @@ sshMode = mode; sshInto = into || null; d.querySelector('.dlg-h strong').textContent = mode === 'files' ? 'Файлы по SSH' : 'SSH-подключение'; - msg.textContent = ''; + say(''); let last = {}; try { last = JSON.parse(localStorage.getItem('lpra.ssh.last') || '{}'); } catch (e) { /* none */ } agents = here ? [here] : [];