feat: links to folders open as folders, on machines and SSH hosts, with their own icon; deleting a link leaves what it leads to
This commit is contained in:
1 parent
4da1cc4200
commit
055020c3bb
6 files changed
+60
-7
No files matched your search
+10
-2
@@ -114,13 +114,21 @@ func fsList(path string) (proto.FSListResult, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, proto.FSEntry{
|
ent := proto.FSEntry{
|
||||||
Name: e.Name(),
|
Name: e.Name(),
|
||||||
IsDir: e.IsDir(),
|
IsDir: e.IsDir(),
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
Mode: info.Mode().String(),
|
Mode: info.Mode().String(),
|
||||||
MTime: info.ModTime().Unix(),
|
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.
|
// Directories first, then alphabetical.
|
||||||
sort.Slice(out, func(i, j int) bool {
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
|||||||
+10
-2
@@ -72,7 +72,9 @@ var (
|
|||||||
// statCommand lists dir's entries as "type|size|mtime|mode|name" lines.
|
// statCommand lists dir's entries as "type|size|mtime|mode|name" lines.
|
||||||
func statCommand(flavour, dir string) string {
|
func statCommand(flavour, dir string) string {
|
||||||
d := shq(strings.TrimSuffix(dir, "/") + "/")
|
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 {
|
switch flavour {
|
||||||
case "bsd":
|
case "bsd":
|
||||||
return loop + "stat -f '%HT|%z|%m|%Sp|%N' -- \"$f\"; done"
|
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.
|
// parseStatLine reads one line of statCommand's output.
|
||||||
func parseStatLine(line string) (proto.FSEntry, bool) {
|
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)
|
p := strings.SplitN(line, "|", 5)
|
||||||
if len(p) != 5 {
|
if len(p) != 5 {
|
||||||
return proto.FSEntry{}, false
|
return proto.FSEntry{}, false
|
||||||
@@ -94,7 +101,8 @@ func parseStatLine(line string) (proto.FSEntry, bool) {
|
|||||||
return proto.FSEntry{}, false
|
return proto.FSEntry{}, false
|
||||||
}
|
}
|
||||||
t := strings.ToLower(p[0])
|
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.
|
// parseLsLine reads a line of "ls -la" for a host with no stat: no times.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"archive/tar"
|
"archive/tar"
|
||||||
"bytes"
|
"bytes"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -193,3 +195,33 @@ func TestUnpackCommandReplaces(t *testing.T) {
|
|||||||
t.Fatalf("error: %s", cmd)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -292,6 +292,8 @@ type FSEntry struct {
|
|||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
MTime int64 `json:"mtime"`
|
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.
|
// FSListResult is returned for TaskFSList.
|
||||||
|
|||||||
@@ -28,18 +28,19 @@
|
|||||||
img: FI + '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 9"/></svg>',
|
img: FI + '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 9"/></svg>',
|
||||||
arc: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M11 4v2M11 8v2M11 12v2"/><rect x="9.5" y="15" width="3" height="3" rx=".6"/></svg>',
|
arc: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M11 4v2M11 8v2M11 12v2"/><rect x="9.5" y="15" width="3" height="3" rx=".6"/></svg>',
|
||||||
media:FI + '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m10 9 5 3-5 3z"/></svg>',
|
media:FI + '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m10 9 5 3-5 3z"/></svg>',
|
||||||
|
dirlink: FI + '<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M10 15.5l4-4M11 11.5h3v3"/></svg>',
|
||||||
link: FI + '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>'
|
link: FI + '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>'
|
||||||
};
|
};
|
||||||
function fileIcon(e) {
|
function fileIcon(e) {
|
||||||
let k = 'file';
|
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 (/^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 (/\.(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 (/\.(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 (/\.(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 (/\.(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';
|
else if (/\.(txt|md|log|csv|rst|cfg|env)$|^(readme|license|makefile|dockerfile)/i.test(e.name)) k = 'text';
|
||||||
return '<span class="ic' + (k === 'dir' ? ' dir' : '') + '">' + FICONS[k] + '</span>';
|
return '<span class="ic' + (k === 'dir' || k === 'dirlink' ? ' dir' : '') + '"' + (e.link ? ' title="ссылка"' : '') + '>' + FICONS[k] + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// A machine's files: the panel's file API of that machine.
|
// A machine's files: the panel's file API of that machine.
|
||||||
|
|||||||
@@ -708,7 +708,9 @@
|
|||||||
async function del(e) {
|
async function del(e) {
|
||||||
// The pane asked about, whatever another pane does meanwhile.
|
// The pane asked about, whatever another pane does meanwhile.
|
||||||
const pane = cur;
|
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;
|
if (!ok) return;
|
||||||
const r = await pane.src.op(e.is_dir ? 'deltree' : 'delete', {path: join(pane.cwd, e.name)});
|
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);
|
r.ok ? (toast('удалено'), pane.go(pane.cwd)) : toast(r.error||'ошибка', true);
|
||||||
|
|||||||
Reference in new issue
Block a user