omarchy-send/internal/tui/view_send.go
28allday cfa719e2b4 Add fuzzy send finder, quick-send, and Nautilus right-click
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>
2026-06-01 14:46:05 +01:00

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")
}