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
|
return
|
||||||
}
|
}
|
||||||
log.Printf("term %s: shell started (user %q)", sid, user)
|
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)
|
a.bridgeStream(parent, sid, term, term.Resize)
|
||||||
log.Printf("term %s: ended", sid)
|
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
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A terminal opened with a folder starts its shell there; a folder that is
|
// 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())
|
t.Fatalf("no pwd in %q", out.String())
|
||||||
return ""
|
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)
|
return nil, addUser(a)
|
||||||
case proto.TaskDBTables, proto.TaskDBRows, proto.TaskDBSet, proto.TaskDBAdd, proto.TaskDBDel, proto.TaskDBSQL:
|
case proto.TaskDBTables, proto.TaskDBRows, proto.TaskDBSet, proto.TaskDBAdd, proto.TaskDBDel, proto.TaskDBSQL:
|
||||||
return dbTask(env)
|
return dbTask(env)
|
||||||
|
case proto.TaskTermCwd:
|
||||||
|
var a proto.TermCwdArgs
|
||||||
|
_ = json.Unmarshal(env.Args, &a)
|
||||||
|
return termCwd(a.Session)
|
||||||
}
|
}
|
||||||
return dispatchFS(env)
|
return dispatchFS(env)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ const (
|
|||||||
TaskDBAdd = "db.add" // add a row
|
TaskDBAdd = "db.add" // add a row
|
||||||
TaskDBDel = "db.del" // delete rows by their keys
|
TaskDBDel = "db.del" // delete rows by their keys
|
||||||
TaskDBSQL = "db.sql" // run a statement typed in the panel
|
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}
|
TaskSSHFSOpen = "sshfs.open" // connect to an SSH host for a file pane; result {id, home}
|
||||||
TaskSSHFSClose = "sshfs.close" // drop that connection
|
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
|
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"`
|
Files int `json:"files"`
|
||||||
Note string `json:"note,omitempty"` // what is going on: connecting, signing in
|
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") {
|
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")
|
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.add": proto.TaskDBAdd,
|
||||||
"db.del": proto.TaskDBDel,
|
"db.del": proto.TaskDBDel,
|
||||||
"db.sql": proto.TaskDBSQL,
|
"db.sql": proto.TaskDBSQL,
|
||||||
|
"termcwd": proto.TaskTermCwd,
|
||||||
}
|
}
|
||||||
|
|
||||||
// fsTaskTimeout bounds how long we wait for an agent to answer a file op.
|
// 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
|
// 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
|
// 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 {
|
type viewPrefs struct {
|
||||||
TabIcons string `json:"tabIcons,omitempty"` // left (default), right, hide
|
TabIcons string `json:"tabIcons,omitempty"` // left (default), right, hide
|
||||||
TermFont int `json:"termFont,omitempty"` // 11-20; 0: the default
|
TermFont int `json:"termFont,omitempty"` // 11-20; 0: the default
|
||||||
Compact bool `json:"compact,omitempty"` // denser file lists
|
Compact bool `json:"compact,omitempty"` // denser file lists
|
||||||
FileTime string `json:"fileTime,omitempty"` // show (default), hide
|
FileTime string `json:"fileTime,omitempty"` // show (default), hide
|
||||||
Motion string `json:"motion,omitempty"` // on (default), off
|
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) }
|
func prefsKey(id int64) string { return "prefs_" + strconv.FormatInt(id, 10) }
|
||||||
@@ -33,6 +35,9 @@ func (p viewPrefs) clean() viewPrefs {
|
|||||||
if p.Motion != "off" {
|
if p.Motion != "off" {
|
||||||
p.Motion = ""
|
p.Motion = ""
|
||||||
}
|
}
|
||||||
|
if p.OpenHere != "off" {
|
||||||
|
p.OpenHere = ""
|
||||||
|
}
|
||||||
return p
|
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="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="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="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></details>' +
|
||||||
' <div class="acct-keysec">' +
|
' <div class="acct-keysec">' +
|
||||||
' <h3>Ключи доступа</h3>' +
|
' <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: '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};
|
: {kind: 'agent', id: o.agent.id, label: o.agent.name};
|
||||||
this.cwd = '/';
|
this.cwd = '/';
|
||||||
|
// startDir: the folder to open first (or a promise of it), not home.
|
||||||
|
this.startDir = o.dir || '';
|
||||||
this.entries = [];
|
this.entries = [];
|
||||||
this.selected = new Set();
|
this.selected = new Set();
|
||||||
this.uploads = [];
|
this.uploads = [];
|
||||||
@@ -202,6 +204,12 @@
|
|||||||
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 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()});
|
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('/');
|
if (r.ok) this.show(r.data); else if (!this.trouble(r.error || '')) this.go('/');
|
||||||
}
|
}
|
||||||
@@ -434,7 +442,7 @@
|
|||||||
|
|
||||||
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, 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)});
|
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.
|
||||||
|
|||||||
@@ -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>',
|
if (here) item(content, '<span class="ico">+</span><span><b>Здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
|
||||||
() => {
|
() => {
|
||||||
closeMenu();
|
closeMenu();
|
||||||
// A terminal from a machine's file pane starts in its folder.
|
// A terminal from a machine's file pane starts in its folder, a
|
||||||
const dir = kind === 'term' && activePane.kind === 'files' ? activePane.cwd : '';
|
// file pane from a machine's terminal in the terminal's (asked of
|
||||||
openPane(kind, here, dir ? {dir} : null);
|
// 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');
|
const box = document.createElement('div');
|
||||||
box.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
|
box.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
|
||||||
|
|||||||
Reference in new issue
Block a user