Send picker: replace the directory browser with a recursive fuzzy finder. Type to match files/folders anywhere under $HOME (sahilm/fuzzy, now a direct dep, compiled in), stage with enter, ctrl+d folders-only, ctrl+s send, ctrl+u re-root up. Folder send is preserved (staging a dir sends it whole). Quick-send: `omarchy-send <paths>` opens the TUI on the device list with the paths pre-staged; selecting a device sends immediately and the window closes itself when the transfer completes. Runs server-less so it coexists with an already-running instance (controller Set* methods nil-guard the server). Nautilus right-click "Send via Omarchy-Send" (desktop-only): a nautilus-python extension that launches quick-send in a floating terminal with the selection pre-staged. Resolves omarchy-send to an absolute path since ~/.local/bin is not on Nautilus's session PATH. install.sh ships it only where Nautilus is present, so headless boxes skip it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
320 lines
8.1 KiB
Go
320 lines
8.1 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"io/fs"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/sahilm/fuzzy"
|
|
)
|
|
|
|
// fzfMaxEntries caps how many paths the recursive walk indexes, so the send
|
|
// finder stays responsive even when rooted at a huge tree. When the cap is hit
|
|
// the header shows a trailing "+".
|
|
const fzfMaxEntries = 50000
|
|
|
|
// fzfSkipDirs are directory names the walk never descends into: build/cache
|
|
// noise that would crowd out real files (and balloon the index) when sending.
|
|
// Dot-directories are skipped too (see walkIndex), except the root itself.
|
|
var fzfSkipDirs = map[string]bool{
|
|
"node_modules": true,
|
|
".git": true,
|
|
".cache": true,
|
|
".svn": true,
|
|
"__pycache__": true,
|
|
".venv": true,
|
|
"vendor": true,
|
|
}
|
|
|
|
// fzfEntry is one indexed path under the finder's root.
|
|
type fzfEntry struct {
|
|
path string // absolute path
|
|
rel string // path relative to the root — the fuzzy-match corpus + display
|
|
dir bool
|
|
}
|
|
|
|
// fzfIndexedMsg carries the result of a background walk back to the model. The
|
|
// root is echoed so a stale result (from a root the user has since changed) can
|
|
// be ignored.
|
|
type fzfIndexedMsg struct {
|
|
root string
|
|
entries []fzfEntry
|
|
trunc bool
|
|
err error
|
|
}
|
|
|
|
// indexFiles walks root in the background and returns its files+folders.
|
|
func indexFiles(root string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
entries, trunc, err := walkIndex(root)
|
|
return fzfIndexedMsg{root: root, entries: entries, trunc: trunc, err: err}
|
|
}
|
|
}
|
|
|
|
// walkIndex recursively lists files and directories under root, skipping
|
|
// dot-directories and the fzfSkipDirs noise, capped at fzfMaxEntries. Unreadable
|
|
// directories are skipped rather than aborting the walk.
|
|
func walkIndex(root string) ([]fzfEntry, bool, error) {
|
|
root = filepath.Clean(root)
|
|
var entries []fzfEntry
|
|
truncated := false
|
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
if d != nil && d.IsDir() {
|
|
return fs.SkipDir // unreadable dir — skip it, keep going
|
|
}
|
|
return nil
|
|
}
|
|
if len(entries) >= fzfMaxEntries {
|
|
truncated = true
|
|
return filepath.SkipAll
|
|
}
|
|
if path == root {
|
|
return nil // don't index the root itself
|
|
}
|
|
name := d.Name()
|
|
if d.IsDir() {
|
|
if strings.HasPrefix(name, ".") || fzfSkipDirs[name] {
|
|
return fs.SkipDir
|
|
}
|
|
}
|
|
rel, rerr := filepath.Rel(root, path)
|
|
if rerr != nil {
|
|
rel = path
|
|
}
|
|
entries = append(entries, fzfEntry{path: path, rel: rel, dir: d.IsDir()})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return entries, truncated, err
|
|
}
|
|
sort.Slice(entries, func(i, j int) bool { return entries[i].rel < entries[j].rel })
|
|
return entries, truncated, nil
|
|
}
|
|
|
|
// recomputeMatches refreshes the ranked match list for the current query and
|
|
// snaps the cursor back to the best match. When fzfDirsOnly is set, only
|
|
// directories are kept — for quickly picking a whole folder to send.
|
|
func (m *Model) recomputeMatches() {
|
|
m.fzfMatches = m.fzfMatches[:0]
|
|
keep := func(i int) bool { return !m.fzfDirsOnly || m.fzfEntries[i].dir }
|
|
if q := strings.TrimSpace(m.fzfQuery.Value()); q == "" {
|
|
for i := range m.fzfEntries {
|
|
if keep(i) {
|
|
m.fzfMatches = append(m.fzfMatches, i)
|
|
}
|
|
}
|
|
} else {
|
|
for _, r := range fuzzy.Find(q, m.fzfRels) {
|
|
if keep(r.Index) {
|
|
m.fzfMatches = append(m.fzfMatches, r.Index)
|
|
}
|
|
}
|
|
}
|
|
m.fzfCursor = 0
|
|
}
|
|
|
|
// toggleStageCursor stages (or unstages) the highlighted entry.
|
|
func (m *Model) toggleStageCursor() {
|
|
if m.fzfCursor < 0 || m.fzfCursor >= len(m.fzfMatches) {
|
|
return
|
|
}
|
|
p := m.fzfEntries[m.fzfMatches[m.fzfCursor]].path
|
|
if contains(m.staged, p) {
|
|
out := m.staged[:0]
|
|
for _, q := range m.staged {
|
|
if q != p {
|
|
out = append(out, q)
|
|
}
|
|
}
|
|
m.staged = out
|
|
return
|
|
}
|
|
m.staged = append(m.staged, p)
|
|
}
|
|
|
|
// updateFzf handles keys on the send (fuzzy-finder) screen. Printable keys edit
|
|
// the query; navigation and actions use arrows and ctrl-combos so they don't
|
|
// collide with typing.
|
|
func (m Model) updateFzf(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "esc":
|
|
m.screen = screenPeers
|
|
return m, nil
|
|
case "ctrl+c":
|
|
m.quitting = true
|
|
return m, tea.Quit
|
|
case "up", "ctrl+p":
|
|
if m.fzfCursor > 0 {
|
|
m.fzfCursor--
|
|
}
|
|
return m, nil
|
|
case "down", "ctrl+n":
|
|
if m.fzfCursor < len(m.fzfMatches)-1 {
|
|
m.fzfCursor++
|
|
}
|
|
return m, nil
|
|
case "enter":
|
|
m.toggleStageCursor()
|
|
return m, nil
|
|
case "ctrl+d":
|
|
m.fzfDirsOnly = !m.fzfDirsOnly
|
|
m.recomputeMatches()
|
|
return m, nil
|
|
case "ctrl+s":
|
|
if len(m.staged) > 0 && m.target != nil && m.ctrl != nil {
|
|
m.sendPeer = m.target
|
|
m.sendPaths = m.staged
|
|
m.pendingMsg = "" // a file send, not a message
|
|
m.ctrl.Send(*m.target, m.staged, "")
|
|
m.staged = nil
|
|
m.screen = screenTransfers
|
|
}
|
|
return m, nil
|
|
case "ctrl+u":
|
|
if parent := filepath.Dir(m.fzfRoot); parent != "" && parent != m.fzfRoot {
|
|
m.fzfRoot = parent
|
|
m.fzfIndexing = true
|
|
m.fzfErr = ""
|
|
m.fzfEntries, m.fzfRels, m.fzfMatches, m.fzfCursor = nil, nil, nil, 0
|
|
return m, indexFiles(parent)
|
|
}
|
|
return m, nil
|
|
}
|
|
var cmd tea.Cmd
|
|
m.fzfQuery, cmd = m.fzfQuery.Update(msg)
|
|
m.recomputeMatches()
|
|
return m, cmd
|
|
}
|
|
|
|
// sendView renders the fuzzy-finder send screen: header, query, ranked matches,
|
|
// and the staged panel — sized to fill the framed area.
|
|
func (m Model) sendView() string {
|
|
target := ""
|
|
if m.target != nil {
|
|
target = m.target.Info.Alias
|
|
}
|
|
|
|
status := collapseHome(m.fzfRoot)
|
|
if m.fzfIndexing {
|
|
status += " · indexing…"
|
|
} else {
|
|
status += fmt.Sprintf(" · %d items", len(m.fzfEntries))
|
|
if m.fzfTrunc {
|
|
status += "+"
|
|
}
|
|
}
|
|
if m.fzfDirsOnly {
|
|
status += " · folders only"
|
|
}
|
|
header := titleStyle.Render("Send to "+target) + " " + headerStyle.Render(status)
|
|
|
|
var errLine string
|
|
if m.fzfErr != "" {
|
|
errLine = lipgloss.NewStyle().Foreground(bad).Render(" "+m.fzfErr) + "\n"
|
|
}
|
|
|
|
staged := m.stagedPanel()
|
|
|
|
_, ih := innerDims(m.width, m.height)
|
|
used := 2 + lipgloss.Height(staged) // header + query rows + staged panel
|
|
if errLine != "" {
|
|
used++
|
|
}
|
|
rows := ih - used
|
|
if rows < 1 {
|
|
rows = 1
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString(header + "\n")
|
|
b.WriteString(m.fzfQuery.View() + "\n")
|
|
b.WriteString(errLine)
|
|
b.WriteString(m.fzfListView(rows) + "\n")
|
|
b.WriteString(staged)
|
|
return b.String()
|
|
}
|
|
|
|
// fzfListView renders exactly rows lines of the match window around the cursor.
|
|
func (m Model) fzfListView(rows int) string {
|
|
if rows < 1 {
|
|
rows = 1
|
|
}
|
|
switch {
|
|
case m.fzfIndexing && len(m.fzfEntries) == 0:
|
|
return padLines(headerStyle.Render(" indexing "+collapseHome(m.fzfRoot)+" …"), rows)
|
|
case len(m.fzfMatches) == 0:
|
|
hint := " no files here"
|
|
switch {
|
|
case strings.TrimSpace(m.fzfQuery.Value()) != "":
|
|
hint = " no matches"
|
|
case m.fzfDirsOnly:
|
|
hint = " no folders here"
|
|
}
|
|
return padLines(headerStyle.Render(hint), rows)
|
|
}
|
|
|
|
// Scroll so the cursor stays within the visible window.
|
|
start := 0
|
|
if m.fzfCursor >= rows {
|
|
start = m.fzfCursor - rows + 1
|
|
}
|
|
end := start + rows
|
|
if end > len(m.fzfMatches) {
|
|
end = len(m.fzfMatches)
|
|
if start = end - rows; start < 0 {
|
|
start = 0
|
|
}
|
|
}
|
|
|
|
dirStyle := lipgloss.NewStyle().Foreground(accent)
|
|
selStyle := lipgloss.NewStyle().Foreground(accent).Bold(true)
|
|
dotStyle := lipgloss.NewStyle().Foreground(good)
|
|
|
|
var b strings.Builder
|
|
for i := start; i < end; i++ {
|
|
e := m.fzfEntries[m.fzfMatches[i]]
|
|
label := collapseHome(e.path)
|
|
if e.dir {
|
|
label += "/"
|
|
}
|
|
cursor := " "
|
|
if i == m.fzfCursor {
|
|
cursor = selStyle.Render("▌ ")
|
|
}
|
|
dot := " "
|
|
if contains(m.staged, e.path) {
|
|
dot = dotStyle.Render("●")
|
|
}
|
|
switch {
|
|
case i == m.fzfCursor:
|
|
label = selStyle.Render(label)
|
|
case e.dir:
|
|
label = dirStyle.Render(label)
|
|
default:
|
|
label = valueStyle.Render(label)
|
|
}
|
|
if i > start {
|
|
b.WriteByte('\n')
|
|
}
|
|
b.WriteString(cursor + dot + " " + label)
|
|
}
|
|
return padLines(b.String(), rows)
|
|
}
|
|
|
|
// padLines forces s to exactly n lines: truncating extra lines and padding short
|
|
// blocks with blanks, so the surrounding layout stays stable.
|
|
func padLines(s string, n int) string {
|
|
lines := strings.Split(s, "\n")
|
|
if len(lines) > n {
|
|
lines = lines[:n]
|
|
}
|
|
for len(lines) < n {
|
|
lines = append(lines, "")
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|