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:
redtea committed 2026-10-16 18:28:15 +03:00
1 parent 57c2f1338f
commit 087eabf3b4
12 files changed
+163 -6

No files matched your search

+1 -1
View File
@@ -1 +1 @@
0.2.0
0.2.1
+2
View File
@@ -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)
}
+62
View File
@@ -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
}
+53
View File
@@ -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")
}
}
+4
View File
@@ -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)
}
+6
View File
@@ -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"`
}
+8
View File
@@ -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)
}
}
+1
View File
@@ -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.
+6 -1
View File
@@ -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
}
+1
View File
@@ -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>' +
+9 -1
View File
@@ -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.
+10 -3
View File
@@ -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>';