diff --git a/internal/app/app.go b/internal/app/app.go index d776cd5..ed78e5f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1032,7 +1032,7 @@ func (m Model) startFuzzyFind() (tea.Model, tea.Cmd) { func (m Model) startView() (tea.Model, tea.Cmd) { e := m.activePanel().CurrentEntry() - if e == nil || e.IsDir() { + if e == nil || e.IsDir() || m.activePanel().IsSymlinkToDir(e) { return m, nil } if !m.activePanel().IsLocal() { @@ -1047,7 +1047,7 @@ func (m Model) startView() (tea.Model, tea.Cmd) { func (m Model) startEdit() (tea.Model, tea.Cmd) { e := m.activePanel().CurrentEntry() - if e == nil || e.IsDir() { + if e == nil || e.IsDir() || m.activePanel().IsSymlinkToDir(e) { return m, nil } if !m.activePanel().IsLocal() { @@ -1297,7 +1297,7 @@ func (m *Model) syncQuickView() tea.Cmd { return nil } entry := p.CurrentEntry() - isDir := entry != nil && entry.IsDir() + isDir := entry != nil && (entry.IsDir() || p.IsSymlinkToDir(entry)) // Archive entries have no readable stream; local and remote both do. available := p.Location().Kind != vfs.KindArchive // A remote file comes back as a quickview.FileLoadedMsg. diff --git a/internal/ui/panel/panel.go b/internal/ui/panel/panel.go index cca4496..142f4e1 100644 --- a/internal/ui/panel/panel.go +++ b/internal/ui/panel/panel.go @@ -155,6 +155,20 @@ func (m Model) InArchive() bool { return m.Location().Kind == vfs.KindArchive } +// IsSymlinkToDir returns whether e is a symlink that resolves to a directory. +func (m Model) IsSymlinkToDir(e fs.DirEntry) bool { + if m.InArchive() || e.Type()&fs.ModeSymlink == 0 { + return false + } + info, err := m.Location().FS.Stat(m.CurrentPath()) + return err == nil && info.IsDir() +} + +// IsDirLike reports whether e behaves like a directory (real dir or symlink-to-dir). +func IsDirLike(m *Model, e fs.DirEntry) bool { + return e.IsDir() || m.IsSymlinkToDir(e) +} + // LocationLabel is the display string for a nested location, or "". func (m Model) LocationLabel() string { return m.Location().Label @@ -277,7 +291,7 @@ func (m *Model) HandleDirLoaded(msg DirLoadedMsg) { all = append(all, e) } - SortEntries(all, m.sortMode) + SortEntries(all, m.sortMode, func(e fs.DirEntry) bool { return IsDirLike(m, e) }) m.entries = all // Cache FileInfo @@ -442,7 +456,7 @@ func (m *Model) handleEnter() tea.Cmd { if e == nil { return nil } - if e.IsDir() { + if e.IsDir() || m.IsSymlinkToDir(e) { return m.enterDir() } @@ -466,7 +480,7 @@ func (m *Model) handleEnter() tea.Cmd { func (m *Model) handleSpace() tea.Cmd { e := m.CurrentEntry() - if e == nil || e.IsDir() || !m.IsLocal() { + if e == nil || IsDirLike(m, e) || !m.IsLocal() { return nil } // Space on file = preview @@ -496,7 +510,7 @@ func (m *Model) enterDir() tea.Cmd { if e == nil { return nil } - if !e.IsDir() { + if !e.IsDir() && !m.IsSymlinkToDir(e) { return nil } if e.Name() == ".." { @@ -624,7 +638,7 @@ func (m *Model) InvertSelection() { // ChangeSortMode cycles to the next sort mode and re-sorts. func (m *Model) ChangeSortMode() { m.sortMode = (m.sortMode + 1) % 4 - SortEntries(m.entries, m.sortMode) + SortEntries(m.entries, m.sortMode, func(e fs.DirEntry) bool { return IsDirLike(m, e) }) } // parentEntry is a synthetic ".." directory entry. diff --git a/internal/ui/panel/panel_test.go b/internal/ui/panel/panel_test.go index 77ab529..1eb7cb4 100644 --- a/internal/ui/panel/panel_test.go +++ b/internal/ui/panel/panel_test.go @@ -254,3 +254,43 @@ func (e *nameOnlyEntry) IsDir() bool { return e.isDir } func (e *nameOnlyEntry) Type() fs.FileMode { return 0 } func (e *nameOnlyEntry) Info() (fs.FileInfo, error) { return nil, nil } func (e *nameOnlyEntry) String() string { return e.name } + +// symlinkToDirEntry is a minimal fs.DirEntry that behaves like a directory but +// reports IsDir=false (same as os.ReadDir on a symlink-to-dir). +type symlinkToDirEntry struct { + nameOnlyEntry +} + +func (e *symlinkToDirEntry) Type() fs.FileMode { return fs.ModeSymlink } + +func TestSortEntriesSymlinkGroupsWithDirs(t *testing.T) { + entries := []fs.DirEntry{ + &nameOnlyEntry{name: "b.txt", isDir: false}, + &nameOnlyEntry{name: "a.txt", isDir: false}, + &symlinkToDirEntry{nameOnlyEntry{name: "d-link"}}, + &nameOnlyEntry{name: "c.txt", isDir: false}, + &nameOnlyEntry{name: "real-dir", isDir: true}, + &nameOnlyEntry{name: "a-dir", isDir: true}, + } + SortEntries(entries, SortByName, func(e fs.DirEntry) bool { + if e.Type()&fs.ModeSymlink != 0 { + return true // simulated symlink-to-dir: group with dirs + } + return e.IsDir() + }) + + if got := entries[0].Name(); got != "a-dir" { + t.Errorf("first = %q, want dir", got) + } + if got := entries[1].Name(); got != "d-link" { + t.Errorf("second = %q, want symlink-to-dir grouped with dirs", got) + } + if got := entries[2].Name(); got != "real-dir" { + t.Errorf("third = %q, want dir", got) + } + for i, name := range []string{"a-dir", "d-link", "real-dir"} { + if entries[i].Name() != name { + t.Errorf("entries[%d] = %q, want %q", i, entries[i].Name(), name) + } + } +} diff --git a/internal/ui/panel/panel_view.go b/internal/ui/panel/panel_view.go index c84ed0b..c3ba809 100644 --- a/internal/ui/panel/panel_view.go +++ b/internal/ui/panel/panel_view.go @@ -91,7 +91,7 @@ func (m Model) renderRow(idx, width int, th theme.Theme) string { info := m.infos[idx] name := entry.Name() - isDir := entry.IsDir() + isDir := IsDirLike(&m, entry) isCursor := idx == m.cursor && m.active isSelected := m.selected[idx] diff --git a/internal/ui/panel/sort.go b/internal/ui/panel/sort.go index ee166a7..c67c613 100644 --- a/internal/ui/panel/sort.go +++ b/internal/ui/panel/sort.go @@ -17,7 +17,10 @@ const ( ) // SortEntries sorts directory entries. Directories always come first. -func SortEntries(entries []fs.DirEntry, mode SortMode) { +// isDir classifies an entry as directory-like; callers should use it for both +// real directories and symlinks that resolve to directories so those sort into +// the same group. +func SortEntries(entries []fs.DirEntry, mode SortMode, isDir func(fs.DirEntry) bool) { sort.SliceStable(entries, func(i, j int) bool { a, b := entries[i], entries[j] @@ -30,8 +33,8 @@ func SortEntries(entries []fs.DirEntry, mode SortMode) { } // Directories before files - aDir := a.IsDir() - bDir := b.IsDir() + aDir := isDir(a) + bDir := isDir(b) if aDir != bDir { return aDir }