From 055020c3bb588fd836de1040f509a90b3dfd259d Mon Sep 17 00:00:00 2001 From: iRedTea Date: Fri, 16 Oct 2026 09:34:32 +0300 Subject: [PATCH] feat: links to folders open as folders, on machines and SSH hosts, with their own icon; deleting a link leaves what it leads to --- internal/agent/fs.go | 12 ++++++++-- internal/agent/sshfs.go | 12 ++++++++-- internal/agent/sshfs_test.go | 32 +++++++++++++++++++++++++ internal/proto/proto.go | 2 ++ internal/server/web/static/filepane.js | 5 ++-- internal/server/web/static/fileviews.js | 4 +++- 6 files changed, 60 insertions(+), 7 deletions(-) diff --git a/internal/agent/fs.go b/internal/agent/fs.go index 6a3248f..b594c2e 100644 --- a/internal/agent/fs.go +++ b/internal/agent/fs.go @@ -114,13 +114,21 @@ func fsList(path string) (proto.FSListResult, error) { if err != nil { continue } - out = append(out, proto.FSEntry{ + ent := proto.FSEntry{ Name: e.Name(), IsDir: e.IsDir(), Size: info.Size(), Mode: info.Mode().String(), MTime: info.ModTime().Unix(), - }) + } + // A link to a folder opens like one. + if info.Mode()&os.ModeSymlink != 0 { + ent.Link = true + if t, err := os.Stat(filepath.Join(abs, e.Name())); err == nil && t.IsDir() { + ent.IsDir = true + } + } + out = append(out, ent) } // Directories first, then alphabetical. sort.Slice(out, func(i, j int) bool { diff --git a/internal/agent/sshfs.go b/internal/agent/sshfs.go index 6963987..3434575 100644 --- a/internal/agent/sshfs.go +++ b/internal/agent/sshfs.go @@ -72,7 +72,9 @@ var ( // statCommand lists dir's entries as "type|size|mtime|mode|name" lines. func statCommand(flavour, dir string) string { d := shq(strings.TrimSuffix(dir, "/") + "/") - loop := "cd " + d + " && for f in .[!.]* ..?* *; do [ -e \"$f\" ] || [ -L \"$f\" ] || continue; " + // Each line starts with LD for a link to a folder, so it opens like one. + loop := "cd " + d + " && for f in .[!.]* ..?* *; do [ -e \"$f\" ] || [ -L \"$f\" ] || continue; " + + "t=; [ -L \"$f\" ] && [ -d \"$f\" ] && t=LD; printf '%s|' \"$t\"; " switch flavour { case "bsd": return loop + "stat -f '%HT|%z|%m|%Sp|%N' -- \"$f\"; done" @@ -84,6 +86,11 @@ func statCommand(flavour, dir string) string { // parseStatLine reads one line of statCommand's output. func parseStatLine(line string) (proto.FSEntry, bool) { + // The LD mark of statCommand: empty, or LD for a link to a folder. + mark := "" + if i := strings.IndexByte(line, '|'); i >= 0 && (line[:i] == "" || line[:i] == "LD") { + mark, line = line[:i], line[i+1:] + } p := strings.SplitN(line, "|", 5) if len(p) != 5 { return proto.FSEntry{}, false @@ -94,7 +101,8 @@ func parseStatLine(line string) (proto.FSEntry, bool) { return proto.FSEntry{}, false } t := strings.ToLower(p[0]) - return proto.FSEntry{Name: p[4], IsDir: strings.Contains(t, "directory"), Size: size, MTime: mt, Mode: p[3]}, true + return proto.FSEntry{Name: p[4], IsDir: strings.Contains(t, "directory") || mark == "LD", Size: size, MTime: mt, Mode: p[3], + Link: strings.Contains(t, "link")}, true } // parseLsLine reads a line of "ls -la" for a host with no stat: no times. diff --git a/internal/agent/sshfs_test.go b/internal/agent/sshfs_test.go index a6b2ac1..b3797aa 100644 --- a/internal/agent/sshfs_test.go +++ b/internal/agent/sshfs_test.go @@ -4,6 +4,8 @@ import ( "archive/tar" "bytes" "io" + "os" + "path/filepath" "strings" "testing" "time" @@ -193,3 +195,33 @@ func TestUnpackCommandReplaces(t *testing.T) { t.Fatalf("error: %s", cmd) } } + +// A link to a folder lists as a folder (marked as a link), so it opens. +func TestParseStatLineFolderLink(t *testing.T) { + e, ok := parseStatLine("LD|symbolic link|7|1700000000|lrwxrwxrwx|www") + if !ok || !e.IsDir || !e.Link || e.Name != "www" { + t.Fatalf("%+v %v", e, ok) + } + e, ok = parseStatLine("|regular file|5|1700000000|-rw-r--r--|a|b") + if !ok || e.IsDir || e.Name != "a|b" { + t.Fatalf("plain: %+v %v", e, ok) + } + if !strings.Contains(statCommand("gnu", "/x"), `[ -L "$f" ] && [ -d "$f" ]`) { + t.Fatal("the command does not mark folder links") + } +} + +func TestListFolderLink(t *testing.T) { + d := t.TempDir() + _ = os.Mkdir(filepath.Join(d, "real"), 0o755) + _ = os.Symlink(filepath.Join(d, "real"), filepath.Join(d, "ln")) + r, err := fsList(d) + if err != nil { + t.Fatal(err) + } + for _, e := range r.Entries { + if e.Name == "ln" && (!e.IsDir || !e.Link) { + t.Fatalf("link: %+v", e) + } + } +} diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 6df5076..4a5e208 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -292,6 +292,8 @@ type FSEntry struct { Size int64 `json:"size"` Mode string `json:"mode"` MTime int64 `json:"mtime"` + // Link: a symbolic link (IsDir then says where it leads: a folder). + Link bool `json:"link,omitempty"` } // FSListResult is returned for TaskFSList. diff --git a/internal/server/web/static/filepane.js b/internal/server/web/static/filepane.js index a3d4882..815fbde 100644 --- a/internal/server/web/static/filepane.js +++ b/internal/server/web/static/filepane.js @@ -28,18 +28,19 @@ img: FI + '', arc: FI + '', media:FI + '', + dirlink: FI + '', link: FI + '' }; function fileIcon(e) { let k = 'file'; - if (e.is_dir) k = 'dir'; + if (e.is_dir) k = e.link ? 'dirlink' : 'dir'; // a link to a folder opens like one else if (/^L/.test(e.mode || '')) k = 'link'; else if (/\.(png|jpe?g|gif|webp|bmp|ico|svg|avif|heic)$/i.test(e.name)) k = 'img'; else if (/\.(zip|tar|gz|tgz|bz2|xz|zst|7z|rar|deb|rpm)$/i.test(e.name)) k = 'arc'; else if (/\.(mp4|m4v|webm|mov|mkv|avi|mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(e.name)) k = 'media'; else if (/\.(js|ts|go|py|rb|rs|c|h|cpp|java|kt|php|sh|bash|zsh|json|ya?ml|toml|html?|css|sql|lua|pl|swift|vue|jsx|tsx|ini|conf|xml)$/i.test(e.name)) k = 'code'; else if (/\.(txt|md|log|csv|rst|cfg|env)$|^(readme|license|makefile|dockerfile)/i.test(e.name)) k = 'text'; - return '' + FICONS[k] + ''; + return '' + FICONS[k] + ''; } // A machine's files: the panel's file API of that machine. diff --git a/internal/server/web/static/fileviews.js b/internal/server/web/static/fileviews.js index 0581dea..f0cd738 100644 --- a/internal/server/web/static/fileviews.js +++ b/internal/server/web/static/fileviews.js @@ -708,7 +708,9 @@ async function del(e) { // The pane asked about, whatever another pane does meanwhile. const pane = cur; - const ok = await UI.confirm(e.is_dir ? 'Удалить папку «' + e.name + '» со всем содержимым?' : 'Удалить файл «' + e.name + '»?', {ok: 'Удалить', danger: true}); + // A link goes alone: what it leads to stays. + const ok = await UI.confirm(e.link ? 'Удалить ссылку «' + e.name + '»? То, куда она ведёт, останется.' + : e.is_dir ? 'Удалить папку «' + e.name + '» со всем содержимым?' : 'Удалить файл «' + e.name + '»?', {ok: 'Удалить', danger: true}); if (!ok) return; const r = await pane.src.op(e.is_dir ? 'deltree' : 'delete', {path: join(pane.cwd, e.name)}); r.ok ? (toast('удалено'), pane.go(pane.cwd)) : toast(r.error||'ошибка', true);