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

+56 -2
View File
@@ -115,7 +115,8 @@ func (a *Agent) sshfsOpen(ctx context.Context, t proto.SSHTarget) (any, error) {
return nil, err
}
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")
switch {
case strings.Contains(out, "GNU"):
@@ -132,7 +133,18 @@ func (a *Agent) sshfsOpen(ctx context.Context, t proto.SSHTarget) (any, error) {
id := randHex(12)
sshfsConns.Store(id, c)
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.
@@ -318,6 +330,20 @@ func (a *Agent) sshfsOp(c *sshfsConn, op string, raw json.RawMessage) (any, erro
var p proto.FSCopyArgs
_ = json.Unmarshal(raw, &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":
var p proto.FSPathArgs
_ = 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("неизвестная операция")
}
// 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
// transfer leaves the old file as it was.
func writeCommand(p string) string {
+21
View File
@@ -106,3 +106,24 @@ func TestRenameTarDropsOtherEntries(t *testing.T) {
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)
}
}