feat: long names in questions cut with … and the window as wide as its words; SSH dialog hides «Вход» for a saved host and says «подключение…» quietly; zip and unzip on SSH hosts without them through Python's zipfile
This commit is contained in:
1 parent
6db78399ac
commit
434900a168
5 files changed
+40
-20
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -573,7 +573,7 @@
|
||||
'<div class="frow"><label class="f grow"><span>Хост</span><input type="text" name="host" placeholder="example.com или 10.0.0.5" required autocapitalize="off" spellcheck="false"></label>' +
|
||||
'<label class="f port"><span>Порт</span><input type="number" name="port" min="1" max="65535" value="22" required></label></div>' +
|
||||
'<label class="f"><span>Пользователь</span><input type="text" name="user" placeholder="root" required autocapitalize="off" spellcheck="false"></label>' +
|
||||
'<div class="f"><span>Вход</span><div class="seg" role="tablist">' +
|
||||
'<div class="f auth-row"><span>Вход</span><div class="seg" role="tablist">' +
|
||||
'<button type="button" data-auth="password" class="on">Пароль</button><button type="button" data-auth="key">Ключ</button><button type="button" data-auth="agent">Ключи агента</button></div></div>' +
|
||||
'<div class="auth" data-for="password"><input type="password" name="password" placeholder="пароль" autocomplete="off"></div>' +
|
||||
'<div class="auth" data-for="key" hidden>' +
|
||||
@@ -588,6 +588,8 @@
|
||||
'</div></form>';
|
||||
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] : [];
|
||||
|
||||
Reference in new issue
Block a user