feat: «Здесь» from a machine's terminal opens the file pane in the terminal's folder (asked of the agent); Аккаунт › Вид can turn «Здесь» back to the home folder. Version 0.2.1
This commit is contained in:
1 parent
57c2f1338f
commit
087eabf3b4
12 files changed
+163
-6
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
0.2.0
|
||||
0.2.1
|
||||
@@ -26,6 +26,8 @@ func (a *Agent) runTerminal(parent context.Context, sid, user, dir string) {
|
||||
return
|
||||
}
|
||||
log.Printf("term %s: shell started (user %q)", sid, user)
|
||||
termsBySID.Store(sid, term)
|
||||
defer termsBySID.Delete(sid)
|
||||
a.bridgeStream(parent, sid, term, term.Resize)
|
||||
log.Printf("term %s: ended", sid)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// termsBySID are the running terminals by their session, so the panel can
|
||||
// ask where one of them is.
|
||||
var termsBySID sync.Map // sid -> *PTY
|
||||
|
||||
// Cwd is the folder the terminal is in now: that of the program in front
|
||||
// (the shell, or what it runs), else of the shell itself.
|
||||
func (p *PTY) Cwd() (string, error) {
|
||||
pid := 0
|
||||
if g, err := unix.IoctlGetInt(int(p.f.Fd()), unix.TIOCGPGRP); err == nil && g > 0 {
|
||||
pid = g
|
||||
}
|
||||
if pid > 0 {
|
||||
if d, err := procCwd(pid); err == nil {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
return procCwd(p.cmd.Process.Pid)
|
||||
}
|
||||
|
||||
// procCwd is a process's working folder: /proc on Linux, lsof elsewhere.
|
||||
func procCwd(pid int) (string, error) {
|
||||
if runtime.GOOS == "linux" {
|
||||
return os.Readlink("/proc/" + strconv.Itoa(pid) + "/cwd")
|
||||
}
|
||||
out, err := exec.Command("lsof", "-a", "-p", strconv.Itoa(pid), "-d", "cwd", "-Fn").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, l := range strings.Split(string(out), "\n") {
|
||||
if strings.HasPrefix(l, "n/") {
|
||||
return l[1:], nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("папка терминала не найдена")
|
||||
}
|
||||
|
||||
// termCwd answers the panel: where the terminal of session sid is now.
|
||||
func termCwd(sid string) (any, error) {
|
||||
v, ok := termsBySID.Load(sid)
|
||||
if !ok {
|
||||
return nil, errors.New("терминал не найден")
|
||||
}
|
||||
d, err := v.(*PTY).Cwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]string{"path": d}, nil
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// A terminal opened with a folder starts its shell there; a folder that is
|
||||
@@ -60,3 +63,53 @@ func ptyPwd(t *testing.T, dir string) string {
|
||||
t.Fatalf("no pwd in %q", out.String())
|
||||
return ""
|
||||
}
|
||||
|
||||
// A terminal tells the folder its shell is in now, after a cd.
|
||||
func TestPTYCwd(t *testing.T) {
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sub := filepath.Join(dir, "next one")
|
||||
if err := os.Mkdir(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("SHELL", "/bin/sh")
|
||||
p, err := StartPTY("", dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer p.Close()
|
||||
go func() { _, _ = io.Copy(io.Discard, p) }()
|
||||
if _, err := p.Write([]byte("cd 'next one'\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got string
|
||||
for i := 0; i < 50; i++ {
|
||||
if got, err = p.Cwd(); err == nil && got == sub {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("Cwd = %q, %v; want %q", got, err, sub)
|
||||
}
|
||||
|
||||
// The panel asks for a terminal's folder by its session.
|
||||
func TestTermCwdTask(t *testing.T) {
|
||||
dir, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
t.Setenv("SHELL", "/bin/sh")
|
||||
p, err := StartPTY("", dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer p.Close()
|
||||
termsBySID.Store("s1", p)
|
||||
defer termsBySID.Delete("s1")
|
||||
res, err := dispatchTask(proto.TaskEnvelope{Type: proto.TaskTermCwd, Args: []byte(`{"session":"s1"}`)})
|
||||
if err != nil || res.(map[string]string)["path"] != dir {
|
||||
t.Fatalf("term.cwd = %v, %v; want %q", res, err, dir)
|
||||
}
|
||||
if _, err := dispatchTask(proto.TaskEnvelope{Type: proto.TaskTermCwd, Args: []byte(`{"session":"nope"}`)}); err == nil {
|
||||
t.Fatal("an unknown session has a folder")
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,10 @@ func dispatchTask(env proto.TaskEnvelope) (any, error) {
|
||||
return nil, addUser(a)
|
||||
case proto.TaskDBTables, proto.TaskDBRows, proto.TaskDBSet, proto.TaskDBAdd, proto.TaskDBDel, proto.TaskDBSQL:
|
||||
return dbTask(env)
|
||||
case proto.TaskTermCwd:
|
||||
var a proto.TermCwdArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return termCwd(a.Session)
|
||||
}
|
||||
return dispatchFS(env)
|
||||
}
|
||||
|
||||
@@ -173,6 +173,7 @@ const (
|
||||
TaskDBAdd = "db.add" // add a row
|
||||
TaskDBDel = "db.del" // delete rows by their keys
|
||||
TaskDBSQL = "db.sql" // run a statement typed in the panel
|
||||
TaskTermCwd = "term.cwd" // the folder a terminal is in now (TermCwdArgs); result {path}
|
||||
TaskSSHFSOpen = "sshfs.open" // connect to an SSH host for a file pane; result {id, home}
|
||||
TaskSSHFSClose = "sshfs.close" // drop that connection
|
||||
TaskSSHFSOp = "sshfs.op" // a file operation on the host: list, read, write, mkdir, delete, deltree, move, copy, stat, chmod
|
||||
@@ -401,3 +402,8 @@ type ShipProgress struct {
|
||||
Files int `json:"files"`
|
||||
Note string `json:"note,omitempty"` // what is going on: connecting, signing in
|
||||
}
|
||||
|
||||
// TermCwdArgs names the terminal session whose folder the panel asks for.
|
||||
type TermCwdArgs struct {
|
||||
Session string `json:"session"`
|
||||
}
|
||||
@@ -140,4 +140,12 @@ func TestViewPrefs(t *testing.T) {
|
||||
if got := post(`{"termFont":99}`); !strings.Contains(got, `"ok":true`) || strings.Contains(get(t, s, c, "/api/panel/account/prefs").Body.String(), "99") {
|
||||
t.Fatal("a font size out of range was kept")
|
||||
}
|
||||
post(`{"openHere":"off"}`)
|
||||
if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); !strings.Contains(got, `"openHere":"off"`) {
|
||||
t.Fatalf("openHere off not kept: %s", got)
|
||||
}
|
||||
post(`{"openHere":"sometimes"}`)
|
||||
if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); strings.Contains(got, "openHere") {
|
||||
t.Fatalf("an unknown openHere was kept: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ var fsOps = map[string]string{
|
||||
"db.add": proto.TaskDBAdd,
|
||||
"db.del": proto.TaskDBDel,
|
||||
"db.sql": proto.TaskDBSQL,
|
||||
"termcwd": proto.TaskTermCwd,
|
||||
}
|
||||
|
||||
// fsTaskTimeout bounds how long we wait for an agent to answer a file op.
|
||||
|
||||
@@ -8,13 +8,15 @@ import (
|
||||
|
||||
// Each user's look of the panel (Аккаунт › Вид), kept on the server so it
|
||||
// is the same on every device: where a tab's icons go, the terminal's font
|
||||
// size, a compact file list, the time column, animations.
|
||||
// size, a compact file list, the time column, animations, and whether a
|
||||
// terminal or file pane opened «Здесь» starts in the active pane's folder.
|
||||
type viewPrefs struct {
|
||||
TabIcons string `json:"tabIcons,omitempty"` // left (default), right, hide
|
||||
TermFont int `json:"termFont,omitempty"` // 11-20; 0: the default
|
||||
Compact bool `json:"compact,omitempty"` // denser file lists
|
||||
FileTime string `json:"fileTime,omitempty"` // show (default), hide
|
||||
Motion string `json:"motion,omitempty"` // on (default), off
|
||||
OpenHere string `json:"openHere,omitempty"` // on (default), off
|
||||
}
|
||||
|
||||
func prefsKey(id int64) string { return "prefs_" + strconv.FormatInt(id, 10) }
|
||||
@@ -33,6 +35,9 @@ func (p viewPrefs) clean() viewPrefs {
|
||||
if p.Motion != "off" {
|
||||
p.Motion = ""
|
||||
}
|
||||
if p.OpenHere != "off" {
|
||||
p.OpenHere = ""
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
' <label><span>Списки файлов</span><select data-pref="compact" data-bool><option value="">обычные</option><option value="1">компактные</option></select></label>' +
|
||||
' <label><span>Время изменения файлов</span><select data-pref="fileTime"><option value="">показывать</option><option value="hide">скрыть</option></select></label>' +
|
||||
' <label><span>Анимации</span><select data-pref="motion"><option value="">включены</option><option value="off">выключены</option></select></label>' +
|
||||
' <label><span>«Здесь» в меню «+»</span><select data-pref="openHere"><option value="">в папке активной вкладки</option><option value="off">в домашней папке</option></select></label>' +
|
||||
' </div></details>' +
|
||||
' <div class="acct-keysec">' +
|
||||
' <h3>Ключи доступа</h3>' +
|
||||
|
||||
@@ -87,6 +87,8 @@
|
||||
? {kind: 'ssh', id: o.agent.id + '|' + o.ssh.user + '@' + o.ssh.host + ':' + (o.ssh.port || 22), label: o.ssh.user + '@' + o.ssh.host}
|
||||
: {kind: 'agent', id: o.agent.id, label: o.agent.name};
|
||||
this.cwd = '/';
|
||||
// startDir: the folder to open first (or a promise of it), not home.
|
||||
this.startDir = o.dir || '';
|
||||
this.entries = [];
|
||||
this.selected = new Set();
|
||||
this.uploads = [];
|
||||
@@ -202,6 +204,12 @@
|
||||
async start() {
|
||||
// Start in the home folder of the machine's user (or the SSH home);
|
||||
// a listing that fails there falls back to the root.
|
||||
const dir = await this.startDir;
|
||||
this.startDir = '';
|
||||
if (dir) {
|
||||
const d = await this.api('list', {path: dir});
|
||||
if (d.ok) { this.show(d.data); return; }
|
||||
}
|
||||
const r = await this.api('list', {path: this.src.home()});
|
||||
if (r.ok) this.show(r.data); else if (!this.trouble(r.error || '')) this.go('/');
|
||||
}
|
||||
@@ -434,7 +442,7 @@
|
||||
|
||||
window.FilePane = FilePane;
|
||||
window.MachineSource = MachineSource;
|
||||
Workspace.paneTypes.files = agent => new FilePane({kind: 'agent', agent});
|
||||
Workspace.paneTypes.files = (agent, o) => new FilePane({kind: 'agent', agent, dir: o.dir});
|
||||
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
|
||||
// pane; the password or key goes to the machine once and is not kept here.
|
||||
|
||||
@@ -567,9 +567,16 @@
|
||||
if (here) item(content, '<span class="ico">+</span><span><b>Здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
|
||||
() => {
|
||||
closeMenu();
|
||||
// A terminal from a machine's file pane starts in its folder.
|
||||
const dir = kind === 'term' && activePane.kind === 'files' ? activePane.cwd : '';
|
||||
openPane(kind, here, dir ? {dir} : null);
|
||||
// A terminal from a machine's file pane starts in its folder, a
|
||||
// file pane from a machine's terminal in the terminal's (asked of
|
||||
// the machine; home if it cannot tell) - unless turned off.
|
||||
const on = !(window.Prefs && Prefs.get().openHere === 'off');
|
||||
let opts = null;
|
||||
if (on && kind === 'term' && activePane.kind === 'files') opts = {dir: activePane.cwd};
|
||||
else if (on && kind === 'files' && activePane.kind === 'term' && activePane.sid) {
|
||||
opts = {dir: new MachineSource(here).op('termcwd', {session: activePane.sid}).then(r => r.ok && r.data ? r.data.path : '')};
|
||||
}
|
||||
openPane(kind, here, opts);
|
||||
});
|
||||
const box = document.createElement('div');
|
||||
box.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
|
||||
|
||||
Reference in new issue
Block a user