Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

515 lines
14 KiB
Go

package model
import (
"os"
"path/filepath"
"sort"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/bjarneo/cliamp/internal/fuzzy"
"github.com/bjarneo/cliamp/player"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/resolve"
)
// fbEntry is a single item in the file browser listing.
type fbEntry struct {
name string
path string
isDir bool
isAudio bool
isParent bool
}
// fbTracksResolvedMsg carries tracks resolved from file browser selections.
type fbTracksResolvedMsg struct {
tracks []playlist.Track
replace bool
toPlaylist bool
targetPlaylist string
}
func (m *Model) fbCount() int {
if m.fileBrowser.searching || m.fileBrowser.search != "" {
return len(m.fileBrowser.filtered)
}
return len(m.fileBrowser.entries)
}
func (m *Model) fbEntry(idx int) fbEntry {
if m.fileBrowser.searching || m.fileBrowser.search != "" {
return m.fileBrowser.entries[m.fileBrowser.filtered[idx]]
}
return m.fileBrowser.entries[idx]
}
func (m Model) fbHelpLine() string {
if m.fileBrowser.searching {
return helpKey("Enter", "Confirm ") + helpKey("Esc", "Cancel ") + helpKey("Type", "Filter")
}
help := helpKey("←↓↑→", "Navigate ") + helpKey("Enter", "Open ") + helpKey("/", "Filter ") +
helpKey("Spc", "Select ") + helpKey("a", "All ") +
helpKey("←", "Back ") + helpKey("~.", "Home/Cwd ")
if os.PathSeparator == '\\' {
help += helpKey("AltCZ", "Drive ")
}
if len(m.fileBrowser.selected) > 0 {
if m.fileBrowser.targetPlaylist == "" {
help += helpKey("w", "Playlist ") + helpKey("R", "Replace ")
} else {
help += helpKey("Enter", "Add to "+m.fileBrowser.targetPlaylist+" ")
}
}
help += helpKey("Esc", "Close")
return help
}
// fbVisible returns the file-browser list height. The browser renders inline in
// the playlist region, so it shares the playlist's row budget.
func (m *Model) fbVisible() int {
return m.effectivePlaylistVisible()
}
// fbMaybeAdjustScroll keeps the cursor visible in the current file-browser window.
func (m *Model) fbMaybeAdjustScroll(visible int) {
clampScroll(&m.fileBrowser.cursor, &m.fileBrowser.scroll, m.fbCount(), visible)
}
// openFileBrowser initialises and shows the file browser overlay.
func (m *Model) openFileBrowser() {
if m.fileBrowser.dir == "" {
dir := os.ExpandEnv(m.initialDir)
if strings.HasPrefix(dir, "~") {
if home, _ := os.UserHomeDir(); home != "" {
dir = filepath.Join(home, dir[1:])
}
}
if dir != "" {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
m.fileBrowser.dir = dir
}
}
if m.fileBrowser.dir == "" {
m.fileBrowser.dir, _ = os.UserHomeDir()
if m.fileBrowser.dir == "" {
m.fileBrowser.dir = "/"
}
}
}
m.fileBrowser.cursor = 0
m.fileBrowser.scroll = 0
m.fileBrowser.selected = make(map[string]bool)
m.fileBrowser.err = ""
m.fileBrowser.searching = false
m.fileBrowser.search = ""
m.fileBrowser.filtered = nil
m.fileBrowser.targetPlaylist = ""
m.loadFBDir()
m.fileBrowser.visible = true
}
func (m *Model) openFileBrowserForPlaylist(name string) {
m.openFileBrowser()
m.fileBrowser.targetPlaylist = name
}
// loadFBDir reads the current directory and populates fbEntries.
func (m *Model) loadFBDir() {
m.fileBrowser.err = ""
m.fileBrowser.cursor = 0
m.fileBrowser.scroll = 0
m.fileBrowser.searching = false
m.fileBrowser.search = ""
m.fileBrowser.filtered = nil
clear(m.fileBrowser.selected)
// Reuse internal memory buffer of m.fileBrowser.entries.
m.fileBrowser.entries = m.fileBrowser.entries[:0]
if cap(m.fileBrowser.entries) > 512 {
// Previous directory list was too large, do not retain memory, re-allocate buffer.
m.fileBrowser.entries = nil
}
m.fileBrowser.entries = append(m.fileBrowser.entries, fbEntry{
name: "..",
path: filepath.Dir(m.fileBrowser.dir),
isDir: true,
isParent: true,
})
// Get entries sorted by name, dirs and files mixed
entries, err := os.ReadDir(m.fileBrowser.dir)
if err != nil {
m.fileBrowser.err = err.Error()
return
}
// Add directories to m.fileBrowser.entries (reuse internal memory),
// add files to files, then append all files to m.fileBrowser.entries, skip dotfiles.
var files []fbEntry
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, ".") {
continue
}
// Detect directories and directory-like entries.
dirType := "" // Name suffix for directories and some non-regular file types.
if e.IsDir() {
dirType = "/"
} else if !e.Type().IsRegular() {
if e.Type()&os.ModeSymlink != 0 && !player.SupportedExts[strings.ToLower(filepath.Ext(name))] {
// Treat symlink as a directory unless it points to media file.
// os.DirEntry has no option to test the type of object symlink points to.
dirType = "@"
} else if os.PathSeparator == '\\' && e.Type()&os.ModeIrregular != 0 {
// Try to support directory junctions on Windows (mklink /J).
// Go do not support such files, it treats them as os.ModeIrregular (?---------).
dirType = "?"
}
}
// Add entry to m.fileBrowser.entries or to files slice
if dirType != "" {
full := name + dirType
m.fileBrowser.entries = append(m.fileBrowser.entries, fbEntry{
name: full,
path: filepath.Join(m.fileBrowser.dir, name),
isDir: true,
})
} else {
if files == nil {
files = make([]fbEntry, 0, 16) // Avoid reallocations
}
files = append(files, fbEntry{
name: name,
path: filepath.Join(m.fileBrowser.dir, name),
isAudio: player.SupportedExts[strings.ToLower(filepath.Ext(name))],
})
}
}
m.fileBrowser.entries = append(m.fileBrowser.entries, files...)
}
// fbUpdateFilter rebuilds the filtered view from the current search query.
// With no query, entries keep their natural directory order; otherwise matches
// are ranked by fuzzy relevance (best match first). The parent ("..") entry is
// never shown while filtering.
func (m *Model) fbUpdateFilter() {
m.fileBrowser.filtered = nil
m.fileBrowser.cursor = 0
m.fileBrowser.scroll = 0
if m.fileBrowser.search == "" {
for i, e := range m.fileBrowser.entries {
if !e.isParent {
m.fileBrowser.filtered = append(m.fileBrowser.filtered, i)
}
}
return
}
type match struct{ idx, score int }
matches := make([]match, 0, len(m.fileBrowser.entries))
for i, e := range m.fileBrowser.entries {
if e.isParent {
continue
}
if score, ok := fuzzy.Match(m.fileBrowser.search, e.name); ok {
matches = append(matches, match{i, score})
}
}
sort.SliceStable(matches, func(a, b int) bool {
return matches[a].score > matches[b].score
})
for _, mt := range matches {
m.fileBrowser.filtered = append(m.fileBrowser.filtered, mt.idx)
}
}
func (m *Model) handleFileBrowserSearchKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.String() {
case "ctrl+c":
m.fileBrowser.visible = false
return m.quit()
case "esc":
m.fileBrowser.searching = false
m.fileBrowser.search = ""
m.fileBrowser.filtered = nil
m.fileBrowser.cursor = m.fileBrowser.savedCursor
m.fileBrowser.scroll = m.fileBrowser.savedScroll
return nil
case "enter":
m.fileBrowser.searching = false
if m.fileBrowser.search == "" {
m.fileBrowser.cursor = m.fileBrowser.savedCursor
m.fileBrowser.scroll = m.fileBrowser.savedScroll
}
return nil
case "down":
m.fileBrowser.searching = false
if m.fbCount() > 0 {
m.fileBrowser.cursor = 0
m.fbMaybeAdjustScroll(m.fbVisible())
}
return nil
case "backspace":
if m.fileBrowser.search != "" {
m.fileBrowser.search = removeLastRune(m.fileBrowser.search)
m.fbUpdateFilter()
} else {
m.fileBrowser.searching = false
m.fileBrowser.cursor = m.fileBrowser.savedCursor
m.fileBrowser.scroll = m.fileBrowser.savedScroll
}
return nil
case "space":
m.fileBrowser.search += " "
m.fbUpdateFilter()
return nil
}
if len(msg.Text) > 0 {
m.fileBrowser.search += msg.Text
m.fbUpdateFilter()
}
return nil
}
// handleFileBrowserKey processes key presses while the file browser is open.
func (m *Model) handleFileBrowserKey(msg tea.KeyPressMsg) tea.Cmd {
if m.fileBrowser.searching {
return m.handleFileBrowserSearchKey(msg)
}
var cd string
switch msg.String() {
case "ctrl+c":
m.fileBrowser.visible = false
return m.quit()
case "esc", "o", "q":
m.fileBrowser.visible = false
m.fileBrowser.searching = false
m.fileBrowser.search = ""
m.fileBrowser.filtered = nil
case "ctrl+x":
m.toggleExpandedView()
m.fbMaybeAdjustScroll(m.fbVisible())
case "/":
m.fileBrowser.savedCursor = m.fileBrowser.cursor
m.fileBrowser.savedScroll = m.fileBrowser.scroll
m.fileBrowser.searching = true
m.fileBrowser.search = ""
m.fbUpdateFilter()
return nil
case "up", "k":
if m.fileBrowser.search != "" && m.fileBrowser.cursor == 0 {
m.fileBrowser.searching = true
return nil
}
count := m.fbCount()
if m.fileBrowser.cursor > 0 {
m.fileBrowser.cursor--
} else if count > 0 {
m.fileBrowser.cursor = count - 1
}
m.fbMaybeAdjustScroll(m.fbVisible())
case "down", "j":
count := m.fbCount()
if m.fileBrowser.cursor < count-1 {
m.fileBrowser.cursor++
} else if count > 0 {
m.fileBrowser.cursor = 0
}
m.fbMaybeAdjustScroll(m.fbVisible())
case "pgup", "ctrl+u":
if m.fileBrowser.cursor > 0 {
visible := m.fbVisible()
m.fileBrowser.cursor -= min(m.fileBrowser.cursor, visible)
m.fbMaybeAdjustScroll(visible)
}
case "pgdown", "ctrl+d":
count := m.fbCount()
if m.fileBrowser.cursor < count-1 {
visible := m.fbVisible()
m.fileBrowser.cursor = min(count-1, m.fileBrowser.cursor+visible)
m.fbMaybeAdjustScroll(visible)
}
case "enter", "right", "l":
if len(m.fileBrowser.selected) > 0 {
return m.fbConfirm(false)
}
if m.fileBrowser.cursor < m.fbCount() {
e := m.fbEntry(m.fileBrowser.cursor)
if e.isDir {
cd = m.fileBrowser.dir
m.fileBrowser.dir = e.path
m.loadFBDir()
if e.name == ".." {
// cd .. and reveal previous directory name in list
for i := range m.fileBrowser.entries {
if m.fileBrowser.entries[i].path == cd {
m.fileBrowser.cursor = i
break
}
}
m.fbMaybeAdjustScroll(m.fbVisible())
}
} else if e.isAudio {
m.fileBrowser.selected[e.path] = true
return m.fbConfirm(false)
}
}
case "backspace", "left", "h":
cd = m.fileBrowser.dir
m.fileBrowser.dir = filepath.Dir(m.fileBrowser.dir)
m.loadFBDir()
// Reveal previous directory name in list
for i := range m.fileBrowser.entries {
if m.fileBrowser.entries[i].path == cd {
m.fileBrowser.cursor = i
break
}
}
m.fbMaybeAdjustScroll(m.fbVisible())
case "~":
if cd, _ = os.UserHomeDir(); cd != "" && m.fileBrowser.dir != cd {
m.fileBrowser.dir = cd
m.loadFBDir()
}
case ".":
if cd, _ = os.Getwd(); cd != "" && m.fileBrowser.dir != cd {
m.fileBrowser.dir = cd
m.loadFBDir()
}
case "space":
count := m.fbCount()
if m.fileBrowser.cursor < count {
e := m.fbEntry(m.fileBrowser.cursor)
if !e.isParent && (e.isAudio || e.isDir) {
if m.fileBrowser.selected[e.path] {
delete(m.fileBrowser.selected, e.path)
} else {
m.fileBrowser.selected[e.path] = true
}
}
if m.fileBrowser.cursor < count-1 {
m.fileBrowser.cursor++
}
}
m.fbMaybeAdjustScroll(m.fbVisible())
case "a":
// Toggle select all audio files in current view.
var selectAll bool
count := m.fbCount()
for i := 0; i < count; i++ {
e := m.fbEntry(i)
// If we found at least one unselected file then all files should be selected:
// set selectAll flag and skip checking selection of remaining files.
if e.isAudio && (selectAll || !m.fileBrowser.selected[e.path]) {
selectAll, m.fileBrowser.selected[e.path] = true, true
}
}
if !selectAll {
// All files in current view selected: clear selection for them.
for i := 0; i < count; i++ {
e := m.fbEntry(i)
if e.isAudio {
delete(m.fileBrowser.selected, e.path)
}
}
}
case "home", "g":
m.fileBrowser.cursor = 0
m.fbMaybeAdjustScroll(m.fbVisible())
case "end", "G":
count := m.fbCount()
if count > 0 {
m.fileBrowser.cursor = count - 1
}
m.fbMaybeAdjustScroll(m.fbVisible())
case "R":
if len(m.fileBrowser.selected) > 0 && m.fileBrowser.targetPlaylist == "" {
return m.fbConfirm(true)
}
case "w":
if len(m.fileBrowser.selected) > 0 && m.fileBrowser.targetPlaylist == "" {
return m.fbConfirmToPlaylist()
}
if m.fileBrowser.cursor < m.fbCount() && m.fileBrowser.targetPlaylist == "" {
e := m.fbEntry(m.fileBrowser.cursor)
if e.isAudio || e.isDir {
m.fileBrowser.selected[e.path] = true
return m.fbConfirmToPlaylist()
}
}
}
// Change drive letter on Windows by pressing alt+[c..z]
if os.PathSeparator == '\\' {
if cd = msg.String(); len(cd) == 5 && strings.HasPrefix(cd, "alt+") && 'c' <= cd[4] && cd[4] <= 'z' {
cd = strings.ToUpper(cd[4:]) + ":\\"
m.fileBrowser.dir = cd
m.loadFBDir()
}
}
return nil
}
// fbConfirm collects selected paths, closes the overlay, and returns an async
// command that resolves the paths into tracks. Paths are emitted in the
// directory listing's natural (alphabetical) order so albums play in track
// order rather than the random map iteration order.
func (m *Model) fbConfirm(replace bool) tea.Cmd {
paths := make([]string, 0, len(m.fileBrowser.selected))
for _, e := range m.fileBrowser.entries {
if m.fileBrowser.selected[e.path] {
paths = append(paths, e.path)
}
}
m.fileBrowser.visible = false
target := m.fileBrowser.targetPlaylist
return func() tea.Msg {
r, err := resolve.Args(paths)
if err != nil {
return err
}
return fbTracksResolvedMsg{tracks: r.Tracks, replace: replace, targetPlaylist: target}
}
}
func (m *Model) fbConfirmToPlaylist() tea.Cmd {
paths := make([]string, 0, len(m.fileBrowser.selected))
for _, e := range m.fileBrowser.entries {
if m.fileBrowser.selected[e.path] {
paths = append(paths, e.path)
}
}
m.fileBrowser.visible = false
return func() tea.Msg {
r, err := resolve.Args(paths)
if err != nil {
return err
}
return fbTracksResolvedMsg{tracks: r.Tracks, toPlaylist: true}
}
}