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>
This commit is contained in:
parent
e66662feb9
commit
cfa719e2b4
10 changed files with 1073 additions and 107 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,3 +1,5 @@
|
|||
/omarchy-send
|
||||
/dist/omarchy-send-linux-*
|
||||
*.tmp
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
|
|
|||
45
README.md
45
README.md
|
|
@ -12,9 +12,13 @@ LocalSend mobile and desktop apps on the same LAN, including their default
|
|||
`/register` handshake, with peer aging.
|
||||
- **Receive** — incoming files are accepted via a prompt (or auto-accepted) and
|
||||
written to the receive directory, with live progress.
|
||||
- **Send** — pick a peer, stage files (or whole folders) with a built-in file
|
||||
picker, and upload them with progress, rate and ETA. Folders are sent
|
||||
recursively with their structure preserved on the receiver.
|
||||
- **Send** — pick a peer, then find what to send with a built-in **recursive
|
||||
fuzzy finder**: type part of a name to match files (and folders) anywhere under
|
||||
your home directory, stage them, and upload with progress, rate and ETA.
|
||||
Folders are sent recursively with their structure preserved on the receiver.
|
||||
- **Right-click in Nautilus** (Omarchy desktop) — "Send via Omarchy-Send" on any
|
||||
file or folder opens the picker in a floating terminal with your selection
|
||||
already staged; just pick a device. Installed only where Nautilus is present.
|
||||
- **Messages** — send a plain-text message to a peer (LocalSend-compatible) and
|
||||
read messages others send you in a dedicated Messages tab. Send the system
|
||||
clipboard as a message, or copy a received one back to the clipboard (uses
|
||||
|
|
@ -30,8 +34,9 @@ LocalSend mobile and desktop apps on the same LAN, including their default
|
|||
- **HTTPS** — generates a self-signed certificate whose fingerprint matches the
|
||||
scheme the official client pins (uppercase-hex SHA-256 of the cert DER), so
|
||||
stock encrypted peers talk to it with no configuration.
|
||||
- **Single static binary**, pure-stdlib protocol layer; only the Charm TUI
|
||||
libraries are external dependencies.
|
||||
- **Single static binary**, pure-stdlib protocol layer; the only external
|
||||
dependencies are the Charm TUI libraries and `sahilm/fuzzy` (the send finder's
|
||||
matcher) — both compiled in, so headless boxes need nothing extra.
|
||||
|
||||
## Install
|
||||
|
||||
|
|
@ -91,6 +96,34 @@ isn't found or the send fails. It starts discovery only — not the receiver —
|
|||
it's safe to run while another `omarchy-send` instance is up. Both `--to` and
|
||||
`--message` are required; file sending stays in the TUI for now.
|
||||
|
||||
### Sending files
|
||||
|
||||
Select a device on the **Devices** tab and press `enter` to open the send
|
||||
finder. It indexes files and folders under your home directory and fuzzy-matches
|
||||
as you type, so you can jump straight to what you want instead of browsing
|
||||
folder by folder:
|
||||
|
||||
- type to filter · `↑`/`↓` move · `enter` stage the highlighted file **or folder**
|
||||
- `ctrl+d` show folders only (to send a whole directory) · `ctrl+s` send · `ctrl+u`
|
||||
move the search root up a level · `esc` back
|
||||
|
||||
Staging a folder sends it whole (its structure is recreated on the receiver).
|
||||
Matching is case-insensitive, and noisy directories (`.git`, `node_modules`,
|
||||
caches, dotfiles…) are skipped to keep the index fast.
|
||||
|
||||
### Right-click send (Nautilus)
|
||||
|
||||
On an Omarchy desktop, the installer adds a **"Send via Omarchy-Send"** entry to
|
||||
the Nautilus context menu. Right-click one or more files or folders (multi-select
|
||||
works) and choose it: a floating terminal opens with your selection pre-staged on
|
||||
the device list — pick a device and it sends, then the window closes itself once
|
||||
the transfer finishes.
|
||||
|
||||
This is a graphical convenience and is **desktop-only**: it's installed only when
|
||||
Nautilus is present, so headless servers don't get it (and don't need it — use
|
||||
the TUI or headless send there). Under the hood it just runs
|
||||
`omarchy-send <paths…>`, which you can call yourself from any terminal.
|
||||
|
||||
### Theming
|
||||
|
||||
On Omarchy, the TUI reads the active theme's `~/.config/omarchy/current/theme/colors.toml`
|
||||
|
|
@ -123,7 +156,7 @@ omarchy-send --auto-accept --pin 2468
|
|||
- `1`–`5` or `tab` — switch between Devices / Transfers / Manage / Messages / Settings
|
||||
- Peers: `enter` send to the selected peer · `m` message · `v` send clipboard · `r` refresh · `/` filter
|
||||
- PIN-protected peers: messages prompt for the PIN and retry, just like file sends
|
||||
- Send picker: `enter` stage a file · `a` add the current folder · `backspace` unstage · `S` send · `esc` back
|
||||
- Send finder: type to fuzzy-filter · `enter` stage file/folder · `ctrl+d` folders-only · `ctrl+s` send · `ctrl+u` up a dir · `esc` back
|
||||
- Incoming prompt: `y` accept · `n` reject
|
||||
- Transfers: `c` clear finished
|
||||
- Messages: `enter` read the full message · `y` copy it to the clipboard · `d`
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
|
@ -40,10 +41,26 @@ func (c controller) Send(p discovery.Peer, paths []string, pin string) { c.sende
|
|||
func (c controller) SendMessage(p discovery.Peer, text, pin string) {
|
||||
c.sender.SendMessage(p, text, pin)
|
||||
}
|
||||
func (c controller) SetAutoAccept(v bool) { c.srv.SetAutoAccept(v) }
|
||||
func (c controller) SetPIN(pin string) { c.srv.SetPIN(pin) }
|
||||
func (c controller) SetReceiveDir(dir string) { c.srv.SetReceiveDir(dir) }
|
||||
func (c controller) SetNotify(v bool) { c.notify.Store(v) }
|
||||
|
||||
// The Set* receiver/server toggles no-op when there is no server — quick-send
|
||||
// mode (Nautilus right-click) runs server-less so it can coexist with an
|
||||
// already-running instance without fighting over the listen port.
|
||||
func (c controller) SetAutoAccept(v bool) {
|
||||
if c.srv != nil {
|
||||
c.srv.SetAutoAccept(v)
|
||||
}
|
||||
}
|
||||
func (c controller) SetPIN(pin string) {
|
||||
if c.srv != nil {
|
||||
c.srv.SetPIN(pin)
|
||||
}
|
||||
}
|
||||
func (c controller) SetReceiveDir(dir string) {
|
||||
if c.srv != nil {
|
||||
c.srv.SetReceiveDir(dir)
|
||||
}
|
||||
}
|
||||
func (c controller) SetNotify(v bool) { c.notify.Store(v) }
|
||||
|
||||
// SetAlias updates the alias across all services and re-announces it.
|
||||
func (c controller) SetAlias(alias string) {
|
||||
|
|
@ -114,6 +131,21 @@ func main() {
|
|||
os.Exit(runHeadlessSend(cfg, *toFlag, *messageFlag, *sendPINFlag, *waitFlag))
|
||||
}
|
||||
|
||||
// Quick-send: any positional arguments are file/folder paths to send (the
|
||||
// Nautilus right-click integration calls `omarchy-send <paths…>`). Open the
|
||||
// TUI with them pre-staged, on the device list.
|
||||
if args := flag.Args(); len(args) > 0 {
|
||||
paths := make([]string, 0, len(args))
|
||||
for _, a := range args {
|
||||
if abs, err := filepath.Abs(a); err == nil {
|
||||
paths = append(paths, abs)
|
||||
} else {
|
||||
paths = append(paths, a)
|
||||
}
|
||||
}
|
||||
os.Exit(runQuickSend(cfg, paths))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
|
|
@ -173,6 +205,37 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
// runQuickSend opens the TUI on the device list with paths pre-staged, so the
|
||||
// user just picks a recipient. It runs server-less (discovery + sender only),
|
||||
// like runHeadlessSend, so it coexists with an already-running receiver instead
|
||||
// of crashing on the busy listen port.
|
||||
func runQuickSend(cfg config.Config, paths []string) int {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
disc := discovery.New(cfg.DeviceInfo())
|
||||
if err := disc.Run(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "discovery: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
sender := client.New(cfg.DeviceInfo())
|
||||
|
||||
// No receiver in quick-send mode, so nothing to notify about.
|
||||
notifyOff := &atomic.Bool{}
|
||||
ctrl := controller{disc: disc, sender: sender, srv: nil, notify: notifyOff}
|
||||
|
||||
p := tea.NewProgram(tui.New(cfg, ctrl, tui.WithStagedFiles(paths)), tea.WithAltScreen())
|
||||
app.BridgeDiscovery(ctx, disc.Events(), p.Send)
|
||||
app.BridgeTransfers(ctx, sender.Events(), p.Send)
|
||||
disc.Announce() // solicit replies immediately rather than waiting a tick
|
||||
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// runHeadlessSend discovers the peer whose alias matches target (case-
|
||||
// insensitively), sends it a plain-text message, and returns a process exit
|
||||
// code. It deliberately starts only discovery — not the HTTP receiver — so it
|
||||
|
|
|
|||
3
go.mod
3
go.mod
|
|
@ -6,6 +6,7 @@ require (
|
|||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/sahilm/fuzzy v0.1.1
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
@ -19,7 +20,6 @@ require (
|
|||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
|
|
@ -29,7 +29,6 @@ require (
|
|||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -28,8 +28,6 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
|
|||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
|
|
|
|||
114
install.sh
114
install.sh
|
|
@ -109,6 +109,120 @@ Keywords=localsend;share;transfer;airdrop;
|
|||
EOF
|
||||
echo "==> Omarchy detected — added floating Walker entry (with icon)."
|
||||
echo " Launch it from Walker by searching 'Omarchy-Send'."
|
||||
|
||||
# Nautilus right-click: "Send via Omarchy-Send". Same approach as Omarchy's
|
||||
# Transcode entry — a nautilus-python MenuProvider that opens the TUI in a
|
||||
# floating presentation terminal with the selected paths pre-staged. Installed
|
||||
# from the clone when present, otherwise written from an embedded copy so the
|
||||
# curl-piped install needs nothing extra.
|
||||
#
|
||||
# Desktop-only: skip entirely when Nautilus isn't installed (e.g. a headless
|
||||
# server). The right-click flow is a graphical convenience and is not required
|
||||
# there — the TUI and headless send still work without it.
|
||||
if command -v nautilus >/dev/null 2>&1; then
|
||||
ext_dir="$HOME/.local/share/nautilus-python/extensions"
|
||||
mkdir -p "$ext_dir"
|
||||
if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/nautilus/omarchy-send.py" ]; then
|
||||
cp "$SCRIPT_DIR/nautilus/omarchy-send.py" "$ext_dir/omarchy-send.py"
|
||||
else
|
||||
cat > "$ext_dir/omarchy-send.py" <<'PY'
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
|
||||
from gi import require_version
|
||||
|
||||
require_version("Nautilus", "4.1")
|
||||
|
||||
from gi.repository import GObject, Gio, Nautilus
|
||||
|
||||
|
||||
# omarchy-send installs to ~/.local/bin, which is often NOT on the PATH the
|
||||
# Nautilus process (and the terminal it spawns) inherits from the graphical
|
||||
# session — so resolve it to an absolute path and invoke it by that.
|
||||
def _resolve(name, fallbacks):
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
return found
|
||||
for path in fallbacks:
|
||||
if path and os.path.isfile(path) and os.access(path, os.X_OK):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _binary():
|
||||
home = os.path.expanduser("~")
|
||||
fallbacks = []
|
||||
bin_dir = os.environ.get("BIN_DIR")
|
||||
if bin_dir:
|
||||
fallbacks.append(os.path.join(bin_dir, "omarchy-send"))
|
||||
fallbacks.append(os.path.join(home, ".local", "bin", "omarchy-send"))
|
||||
fallbacks.append(os.path.join(home, "bin", "omarchy-send"))
|
||||
return _resolve("omarchy-send", fallbacks)
|
||||
|
||||
|
||||
def _wrapper():
|
||||
home = os.path.expanduser("~")
|
||||
fallbacks = [
|
||||
os.path.join(home, ".local", "share", "omarchy", "bin",
|
||||
"omarchy-launch-floating-terminal-with-presentation"),
|
||||
]
|
||||
return _resolve("omarchy-launch-floating-terminal-with-presentation", fallbacks)
|
||||
|
||||
|
||||
class OmarchySendAction(GObject.GObject, Nautilus.MenuProvider):
|
||||
def _launch(self, paths):
|
||||
wrapper = _wrapper()
|
||||
binary = _binary()
|
||||
if not wrapper or not binary:
|
||||
return
|
||||
cmd = shlex.join([binary, *paths])
|
||||
Gio.Subprocess.new([wrapper, cmd], Gio.SubprocessFlags.NONE)
|
||||
|
||||
def _selected_paths(self, files):
|
||||
paths = []
|
||||
seen = set()
|
||||
for file in files:
|
||||
location = file.get_location()
|
||||
if not location:
|
||||
continue
|
||||
path = location.get_path()
|
||||
if path and path not in seen:
|
||||
seen.add(path)
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
def _make_item(self, paths):
|
||||
label = (
|
||||
"Send via Omarchy-Send"
|
||||
if len(paths) == 1
|
||||
else f"Send {len(paths)} items via Omarchy-Send"
|
||||
)
|
||||
item = Nautilus.MenuItem(
|
||||
name="OmarchySendNautilus::send",
|
||||
label=label,
|
||||
icon="omarchy-send",
|
||||
)
|
||||
item.connect("activate", self._on_activate, paths)
|
||||
return item
|
||||
|
||||
def _on_activate(self, _menu, paths):
|
||||
self._launch(paths)
|
||||
|
||||
def get_file_items(self, *args):
|
||||
files = args[0] if len(args) == 1 else args[1]
|
||||
if not _wrapper() or not _binary():
|
||||
return []
|
||||
paths = self._selected_paths(files)
|
||||
if not paths:
|
||||
return []
|
||||
return [self._make_item(paths)]
|
||||
PY
|
||||
fi
|
||||
echo "==> Added Nautilus right-click entry 'Send via Omarchy-Send'."
|
||||
# Restart Nautilus so the extension loads (windows reopen on demand).
|
||||
nautilus -q >/dev/null 2>&1 || true
|
||||
fi # nautilus present
|
||||
else
|
||||
echo "==> Headless system — installed as a plain TUI."
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Package tui implements the Bubble Tea front end. It routes between Devices,
|
||||
// Transfers, Manage (received-file housekeeping) and Settings screens, offers a
|
||||
// file picker for sending, and raises modal prompts for incoming files and for
|
||||
// confirming deletions.
|
||||
// Transfers, Manage (received-file housekeeping), Messages and Settings screens,
|
||||
// offers a recursive fuzzy file finder for sending, and raises modal prompts for
|
||||
// incoming files and for confirming deletions.
|
||||
package tui
|
||||
|
||||
import (
|
||||
|
|
@ -13,7 +13,6 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/filepicker"
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
|
|
@ -80,8 +79,19 @@ type Model struct {
|
|||
peerList list.Model
|
||||
peers map[string]discovery.Peer
|
||||
|
||||
picker filepicker.Model
|
||||
staged []string // file paths queued to send
|
||||
// Send screen: a recursive fuzzy file finder ("fzf") rooted at fzfRoot.
|
||||
fzfQuery textinput.Model // the fuzzy query input
|
||||
fzfRoot string // directory currently indexed
|
||||
fzfEntries []fzfEntry // files+dirs found under fzfRoot
|
||||
fzfRels []string // entry paths relative to fzfRoot (match corpus)
|
||||
fzfMatches []int // indexes into fzfEntries, best-ranked first
|
||||
fzfCursor int // selected row within fzfMatches
|
||||
fzfIndexing bool // a walk is in flight
|
||||
fzfTrunc bool // the walk hit the entry cap
|
||||
fzfDirsOnly bool // show only folders (pick a whole dir to send)
|
||||
fzfErr string // last walk error
|
||||
|
||||
staged []string // file/dir paths queued to send
|
||||
target *discovery.Peer // peer we're sending to
|
||||
|
||||
bar progress.Model
|
||||
|
|
@ -122,12 +132,39 @@ type Model struct {
|
|||
readingMsg *server.ReceivedMessage // non-nil while reading a message full-screen
|
||||
notice string // transient footer flash (e.g. "message sent")
|
||||
|
||||
// quickSend is set by WithStagedFiles: the TUI opens with files already
|
||||
// staged and selecting a device on the Devices screen sends them straight
|
||||
// away (used by the Nautilus right-click integration).
|
||||
quickSend bool
|
||||
// quitAfterSend (also set by WithStagedFiles) auto-closes the window once
|
||||
// the outgoing transfer completes, so the right-click box doesn't linger.
|
||||
// quitPending guards against scheduling more than one quit tick at a time.
|
||||
quitAfterSend bool
|
||||
quitPending bool
|
||||
|
||||
width, height int
|
||||
quitting bool
|
||||
}
|
||||
|
||||
// Option customizes a Model at construction.
|
||||
type Option func(*Model)
|
||||
|
||||
// WithStagedFiles opens the TUI in quick-send mode: paths are pre-staged and
|
||||
// picking a device on the Devices screen sends them immediately, skipping the
|
||||
// file finder. Empty input is a no-op (normal startup).
|
||||
func WithStagedFiles(paths []string) Option {
|
||||
return func(m *Model) {
|
||||
if len(paths) == 0 {
|
||||
return
|
||||
}
|
||||
m.staged = append([]string(nil), paths...)
|
||||
m.quickSend = true
|
||||
m.quitAfterSend = true
|
||||
}
|
||||
}
|
||||
|
||||
// New returns the root model. ctrl may be nil (e.g. in tests).
|
||||
func New(cfg config.Config, ctrl Controller) Model {
|
||||
func New(cfg config.Config, ctrl Controller, opts ...Option) Model {
|
||||
applyTheme(theme.Load()) // match the active Omarchy theme
|
||||
|
||||
l := list.New(nil, deviceDelegate{icons: !cfg.NoIcons}, 0, 0)
|
||||
|
|
@ -146,19 +183,12 @@ func New(cfg config.Config, ctrl Controller) Model {
|
|||
ml.SetShowHelp(false)
|
||||
ml.SetShowStatusBar(false)
|
||||
|
||||
fp := filepicker.New()
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
fp.CurrentDirectory = home
|
||||
}
|
||||
fp.AutoHeight = false
|
||||
fp.Styles.Cursor = fp.Styles.Cursor.Foreground(accent)
|
||||
fp.Styles.Selected = fp.Styles.Selected.Foreground(accent).Bold(true)
|
||||
fp.Styles.Directory = fp.Styles.Directory.Foreground(accent)
|
||||
fp.Styles.File = fp.Styles.File.Foreground(text)
|
||||
fp.Styles.FileSize = fp.Styles.FileSize.Foreground(muted)
|
||||
fp.Styles.Permission = fp.Styles.Permission.Foreground(muted)
|
||||
fp.Styles.Symlink = fp.Styles.Symlink.Foreground(dim)
|
||||
fp.Styles.EmptyDirectory = fp.Styles.EmptyDirectory.Foreground(muted)
|
||||
fzfQuery := textinput.New()
|
||||
fzfQuery.Prompt = "› "
|
||||
fzfQuery.Placeholder = "type to fuzzy-find files…"
|
||||
fzfQuery.CharLimit = 128
|
||||
fzfQuery.Width = 48
|
||||
fzfQuery.PromptStyle = lipgloss.NewStyle().Foreground(accent)
|
||||
|
||||
pin := textinput.New()
|
||||
pin.Placeholder = "PIN"
|
||||
|
|
@ -182,7 +212,7 @@ func New(cfg config.Config, ctrl Controller) Model {
|
|||
mkInput("PIN (blank = disabled)", 16),
|
||||
}
|
||||
|
||||
return Model{
|
||||
m := Model{
|
||||
cfg: cfg,
|
||||
ctrl: ctrl,
|
||||
ips: server.LocalIPs(),
|
||||
|
|
@ -191,7 +221,7 @@ func New(cfg config.Config, ctrl Controller) Model {
|
|||
peers: make(map[string]discovery.Peer),
|
||||
fileList: fl,
|
||||
marked: marked,
|
||||
picker: fp,
|
||||
fzfQuery: fzfQuery,
|
||||
bar: progress.New(progress.WithDefaultGradient(), progress.WithWidth(22)),
|
||||
xferIndex: make(map[string]*xfer),
|
||||
autoAccept: cfg.AutoAccept,
|
||||
|
|
@ -200,6 +230,10 @@ func New(cfg config.Config, ctrl Controller) Model {
|
|||
msgList: ml,
|
||||
composeInput: compose,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
|
@ -216,10 +250,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.peerList.SetSize(iw, lh)
|
||||
m.fileList.SetSize(iw, lh)
|
||||
m.msgList.SetSize(iw, lh)
|
||||
if ph := ih - 8; ph >= 3 {
|
||||
m.picker.Height = ph
|
||||
} else {
|
||||
m.picker.Height = 3
|
||||
if qw := iw - 4; qw > 8 {
|
||||
m.fzfQuery.Width = qw
|
||||
}
|
||||
return m, nil
|
||||
|
||||
|
|
@ -247,6 +279,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m, textinput.Blink
|
||||
}
|
||||
m.applyTransfer(msg.Ev)
|
||||
return m, m.maybeScheduleQuit()
|
||||
|
||||
case autoQuitMsg:
|
||||
// The debounce elapsed — quit only if the send is still complete (a new
|
||||
// file may have started since, e.g. mid-folder).
|
||||
m.quitPending = false
|
||||
if m.allSendsDoneOK() {
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case app.MessageMsg:
|
||||
|
|
@ -255,6 +297,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.notice = "✉ message from " + nonEmpty(msg.Msg.From, "a device")
|
||||
return m, nil
|
||||
|
||||
case fzfIndexedMsg:
|
||||
if msg.root != m.fzfRoot {
|
||||
return m, nil // a stale walk for a root we've since left
|
||||
}
|
||||
m.fzfIndexing = false
|
||||
m.fzfEntries = msg.entries
|
||||
m.fzfTrunc = msg.trunc
|
||||
if msg.err != nil {
|
||||
m.fzfErr = msg.err.Error()
|
||||
} else {
|
||||
m.fzfErr = ""
|
||||
}
|
||||
m.fzfRels = make([]string, len(msg.entries))
|
||||
for i, e := range msg.entries {
|
||||
m.fzfRels[i] = e.rel
|
||||
}
|
||||
m.recomputeMatches()
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
m.notice = "" // any key clears a transient footer notice
|
||||
if m.composing {
|
||||
|
|
@ -286,7 +347,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m.updateAccept(msg)
|
||||
}
|
||||
if m.screen == screenPicker {
|
||||
return m.updatePicker(msg)
|
||||
return m.updateFzf(msg)
|
||||
}
|
||||
if m.screen == screenPeers && m.peerList.FilterState() == list.Filtering {
|
||||
break
|
||||
|
|
@ -445,10 +506,35 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
if m.screen == screenPeers {
|
||||
if it, ok := m.peerList.SelectedItem().(peerItem); ok {
|
||||
peer := it.p
|
||||
// Quick-send (Nautilus right-click): files are already
|
||||
// staged — selecting a device sends them straight away. A
|
||||
// PIN-required peer still routes through the PIN prompt via
|
||||
// sendPeer/sendPaths.
|
||||
if m.quickSend && len(m.staged) > 0 && m.ctrl != nil {
|
||||
m.sendPeer = &peer
|
||||
m.sendPaths = m.staged
|
||||
m.pendingMsg = ""
|
||||
m.ctrl.Send(peer, m.staged, "")
|
||||
m.staged = nil
|
||||
m.quickSend = false
|
||||
m.screen = screenTransfers
|
||||
return m, nil
|
||||
}
|
||||
m.target = &peer
|
||||
m.staged = nil
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
home = "."
|
||||
}
|
||||
m.fzfRoot = home
|
||||
m.fzfQuery.SetValue("")
|
||||
m.fzfQuery.Focus()
|
||||
m.fzfEntries, m.fzfRels, m.fzfMatches, m.fzfCursor = nil, nil, nil, 0
|
||||
m.fzfErr = ""
|
||||
m.fzfDirsOnly = false
|
||||
m.fzfIndexing = true
|
||||
m.screen = screenPicker
|
||||
return m, m.picker.Init()
|
||||
return m, tea.Batch(indexFiles(home), textinput.Blink)
|
||||
}
|
||||
}
|
||||
if m.screen == screenMessages {
|
||||
|
|
@ -463,7 +549,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
|
||||
if m.pending == nil && m.screen == screenPicker {
|
||||
var cmd tea.Cmd
|
||||
m.picker, cmd = m.picker.Update(msg)
|
||||
m.fzfQuery, cmd = m.fzfQuery.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
if m.pending == nil && m.screen == screenPeers {
|
||||
|
|
@ -526,49 +612,6 @@ func (m *Model) deleteSelectedMessage() {
|
|||
m.msgList.SetItems(m.msgItems())
|
||||
}
|
||||
|
||||
// updatePicker handles the file picker / staging screen.
|
||||
func (m Model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.screen = screenPeers
|
||||
return m, nil
|
||||
case "ctrl+c", "q":
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case "a":
|
||||
// Stage the folder currently being browsed; it is expanded into its
|
||||
// files (structure preserved) when the transfer starts.
|
||||
if dir := m.picker.CurrentDirectory; dir != "" && !contains(m.staged, dir) {
|
||||
m.staged = append(m.staged, dir)
|
||||
}
|
||||
return m, nil
|
||||
case "backspace":
|
||||
if len(m.staged) > 0 {
|
||||
m.staged = m.staged[:len(m.staged)-1]
|
||||
}
|
||||
return m, nil
|
||||
case "S":
|
||||
if len(m.staged) > 0 && m.target != nil && m.ctrl != nil {
|
||||
m.sendPeer = m.target
|
||||
m.sendPaths = m.staged
|
||||
m.pendingMsg = "" // this is a file send, not a message
|
||||
m.ctrl.Send(*m.target, m.staged, "")
|
||||
m.staged = nil
|
||||
m.screen = screenTransfers
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.picker, cmd = m.picker.Update(msg)
|
||||
if ok, path := m.picker.DidSelectFile(msg); ok {
|
||||
if !contains(m.staged, path) {
|
||||
m.staged = append(m.staged, path)
|
||||
}
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// beginEdit enters the settings edit form, prefilling current values.
|
||||
func (m Model) beginEdit() (tea.Model, tea.Cmd) {
|
||||
m.editing = true
|
||||
|
|
@ -690,6 +733,36 @@ func (m Model) updateAccept(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
return m, nil
|
||||
}
|
||||
|
||||
// autoQuitMsg fires after the post-send debounce to close a quick-send window.
|
||||
type autoQuitMsg struct{}
|
||||
|
||||
// allSendsDoneOK reports whether every transfer has completed successfully —
|
||||
// the trigger for auto-closing a quick-send window. A still-active, errored, or
|
||||
// cancelled transfer (or none at all) returns false, so the window stays open
|
||||
// for the user to see what happened.
|
||||
func (m *Model) allSendsDoneOK() bool {
|
||||
if len(m.transfers) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, x := range m.transfers {
|
||||
if x.state != "done" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// maybeScheduleQuit arms a debounced auto-quit when a quick-send has fully
|
||||
// completed. The delay lets the user glimpse the ✓ and lets the next file in a
|
||||
// multi-file/folder send start before we re-check at fire time.
|
||||
func (m *Model) maybeScheduleQuit() tea.Cmd {
|
||||
if !m.quitAfterSend || m.quitPending || !m.allSendsDoneOK() {
|
||||
return nil
|
||||
}
|
||||
m.quitPending = true
|
||||
return tea.Tick(1200*time.Millisecond, func(time.Time) tea.Msg { return autoQuitMsg{} })
|
||||
}
|
||||
|
||||
func (m *Model) applyTransfer(ev transfer.Event) {
|
||||
x, ok := m.xferIndex[ev.ID]
|
||||
if !ok {
|
||||
|
|
@ -785,9 +858,17 @@ func (m Model) View() string {
|
|||
switch m.screen {
|
||||
case screenPeers:
|
||||
if len(m.peers) == 0 {
|
||||
body, center = headerStyle.Render("Searching for devices on the network…"), true
|
||||
msg := "Searching for devices on the network…"
|
||||
if m.quickSend {
|
||||
msg += fmt.Sprintf("\n\n%d item(s) ready — pick a device to send.", len(m.staged))
|
||||
}
|
||||
body, center = headerStyle.Render(msg), true
|
||||
} else {
|
||||
body = deviceHeader() + "\n" + m.peerList.View()
|
||||
head := deviceHeader()
|
||||
if m.quickSend {
|
||||
head = titleStyle.Render(fmt.Sprintf("Send %d item(s) — select a device:", len(m.staged))) + "\n" + head
|
||||
}
|
||||
body = head + "\n" + m.peerList.View()
|
||||
}
|
||||
case screenTransfers:
|
||||
if len(m.transfers) == 0 {
|
||||
|
|
@ -817,7 +898,7 @@ func (m Model) View() string {
|
|||
body, center = m.settingsView(), true
|
||||
}
|
||||
case screenPicker:
|
||||
body = m.pickerView()
|
||||
body = m.sendView()
|
||||
}
|
||||
if center {
|
||||
body = centerIn(cw, ih, body)
|
||||
|
|
@ -866,22 +947,6 @@ func (m Model) tabBar() string {
|
|||
return " " + tab("Devices", screenPeers) + tab("Transfers", screenTransfers) + tab("Manage", screenManage) + tab("Messages", screenMessages) + tab("Settings", screenSettings)
|
||||
}
|
||||
|
||||
func (m Model) pickerView() string {
|
||||
target := ""
|
||||
if m.target != nil {
|
||||
target = m.target.Info.Alias
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(titleStyle.Render("Send to " + target))
|
||||
b.WriteString(" ")
|
||||
b.WriteString(headerStyle.Render(collapseHome(m.picker.CurrentDirectory)))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(m.picker.View())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.stagedPanel())
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// stagedPanel renders the queued files as a bordered box (or a hint when empty).
|
||||
func (m Model) stagedPanel() string {
|
||||
border := lipgloss.NewStyle().
|
||||
|
|
@ -889,7 +954,7 @@ func (m Model) stagedPanel() string {
|
|||
BorderForeground(muted).
|
||||
Padding(0, 1)
|
||||
if len(m.staged) == 0 {
|
||||
return border.Render(headerStyle.Render("Nothing staged — enter adds a file, a adds the current folder."))
|
||||
return border.Render(headerStyle.Render("Nothing staged — enter stages the highlighted file or folder."))
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(titleStyle.Render(fmt.Sprintf("Staged · %d", len(m.staged))))
|
||||
|
|
@ -1175,7 +1240,9 @@ func (m Model) footerText() string {
|
|||
case m.editing:
|
||||
return "tab/↑↓ move · enter next · ctrl+s save · esc cancel"
|
||||
case m.screen == screenPicker:
|
||||
return "enter stage file · a add folder · backspace unstage · S send · esc back"
|
||||
return "type filter · ↑↓ move · enter stage · ctrl+d folders-only · ctrl+s send · ctrl+u up-dir · esc back"
|
||||
case m.screen == screenPeers && m.quickSend:
|
||||
return fmt.Sprintf("enter send %d item(s) to selected device · r refresh · q cancel", len(m.staged))
|
||||
case m.screen == screenPeers:
|
||||
return "enter send-to · m message · v send-clipboard · r refresh · / filter · 1-5 · q quit"
|
||||
case m.screen == screenTransfers:
|
||||
|
|
|
|||
261
internal/tui/send_test.go
Normal file
261
internal/tui/send_test.go
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"omarchy-send/internal/config"
|
||||
"omarchy-send/internal/discovery"
|
||||
"omarchy-send/internal/protocol"
|
||||
"omarchy-send/internal/transfer"
|
||||
)
|
||||
|
||||
// fakeCtrl records Send calls for quick-send tests.
|
||||
type fakeCtrl struct {
|
||||
sends int
|
||||
sentPeer discovery.Peer
|
||||
sentPaths []string
|
||||
}
|
||||
|
||||
func (f *fakeCtrl) Announce() {}
|
||||
func (f *fakeCtrl) Send(p discovery.Peer, paths []string, pin string) {
|
||||
f.sends++
|
||||
f.sentPeer = p
|
||||
f.sentPaths = paths
|
||||
}
|
||||
func (f *fakeCtrl) SendMessage(p discovery.Peer, text, pin string) {}
|
||||
func (f *fakeCtrl) SetAutoAccept(bool) {}
|
||||
func (f *fakeCtrl) SetAlias(string) {}
|
||||
func (f *fakeCtrl) SetReceiveDir(string) {}
|
||||
func (f *fakeCtrl) SetPIN(string) {}
|
||||
func (f *fakeCtrl) SetNotify(bool) {}
|
||||
|
||||
// writeTree lays out a small fixture tree under a temp dir for walkIndex tests.
|
||||
func writeTree(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
mk := func(rel string) {
|
||||
p := filepath.Join(root, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
mk("a.txt")
|
||||
mk("sub/b.txt")
|
||||
mk(".hidden/secret.txt") // dot-dir: skipped entirely
|
||||
mk("node_modules/junk.txt") // denylisted dir: skipped entirely
|
||||
return root
|
||||
}
|
||||
|
||||
func TestWalkIndexSkipsNoiseAndDotDirs(t *testing.T) {
|
||||
root := writeTree(t)
|
||||
entries, trunc, err := walkIndex(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if trunc {
|
||||
t.Fatal("did not expect truncation")
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
got[e.rel] = true
|
||||
}
|
||||
for _, want := range []string{"a.txt", "sub", filepath.Join("sub", "b.txt")} {
|
||||
if !got[want] {
|
||||
t.Errorf("expected %q in index, missing", want)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{".hidden", filepath.Join(".hidden", "secret.txt"), "node_modules", filepath.Join("node_modules", "junk.txt")} {
|
||||
if got[unwanted] {
|
||||
t.Errorf("did not expect %q in index", unwanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkIndexExcludesRootItself(t *testing.T) {
|
||||
root := writeTree(t)
|
||||
entries, _, err := walkIndex(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.path == root {
|
||||
t.Fatal("root should not be indexed as an entry")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// seedFinder builds a Model with a hand-set index for match/stage tests.
|
||||
func seedFinder(paths ...string) Model {
|
||||
m := New(config.Config{}, nil)
|
||||
for _, p := range paths {
|
||||
m.fzfEntries = append(m.fzfEntries, fzfEntry{path: p, rel: p})
|
||||
m.fzfRels = append(m.fzfRels, p)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestRecomputeMatchesFuzzyRanks(t *testing.T) {
|
||||
m := seedFinder("Documents/Q3-report.pdf", "Downloads/cat.jpg", "Projects/readme.md")
|
||||
|
||||
(&m).recomputeMatches() // empty query → everything
|
||||
if len(m.fzfMatches) != 3 {
|
||||
t.Fatalf("empty query: want 3 matches, got %d", len(m.fzfMatches))
|
||||
}
|
||||
|
||||
m.fzfQuery.SetValue("report")
|
||||
(&m).recomputeMatches()
|
||||
if len(m.fzfMatches) == 0 {
|
||||
t.Fatal("query 'report' matched nothing")
|
||||
}
|
||||
if best := m.fzfEntries[m.fzfMatches[0]].rel; best != "Documents/Q3-report.pdf" {
|
||||
t.Errorf("best match = %q, want the report", best)
|
||||
}
|
||||
if m.fzfCursor != 0 {
|
||||
t.Errorf("cursor should reset to best match, got %d", m.fzfCursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendViewRenders(t *testing.T) {
|
||||
m := seedFinder("Documents/report.pdf", "Downloads/cat.jpg")
|
||||
m.width, m.height = 90, 28
|
||||
m.screen = screenPicker
|
||||
m.fzfRoot = "/home/test"
|
||||
(&m).recomputeMatches()
|
||||
m.fzfCursor = 1
|
||||
m.staged = []string{"/home/test/Downloads/cat.jpg"}
|
||||
|
||||
out := m.View()
|
||||
if len(out) < 50 {
|
||||
t.Fatalf("send view rendered too little:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "cat.jpg") {
|
||||
t.Errorf("staged/listed file cat.jpg not shown in view:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithStagedFilesEnablesQuickSend(t *testing.T) {
|
||||
m := New(config.Config{}, nil, WithStagedFiles([]string{"/tmp/a.txt", "/tmp/b.txt"}))
|
||||
if !m.quickSend {
|
||||
t.Fatal("WithStagedFiles should enable quickSend")
|
||||
}
|
||||
if len(m.staged) != 2 {
|
||||
t.Fatalf("want 2 pre-staged paths, got %d", len(m.staged))
|
||||
}
|
||||
|
||||
// Empty input must not flip into quick-send mode.
|
||||
plain := New(config.Config{}, nil, WithStagedFiles(nil))
|
||||
if plain.quickSend {
|
||||
t.Error("empty WithStagedFiles should be a no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickSendSelectingDeviceSends(t *testing.T) {
|
||||
fc := &fakeCtrl{}
|
||||
m := New(config.Config{}, fc, WithStagedFiles([]string{"/tmp/a.txt", "/tmp/b.txt"}))
|
||||
|
||||
peer := discovery.Peer{Info: protocol.DeviceInfo{Alias: "Target", Fingerprint: "fp1"}}
|
||||
m.peers["fp1"] = peer
|
||||
m.peerList.SetItems(m.peerItems())
|
||||
|
||||
nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
got := nm.(Model)
|
||||
|
||||
if fc.sends != 1 {
|
||||
t.Fatalf("want exactly 1 send, got %d", fc.sends)
|
||||
}
|
||||
if len(fc.sentPaths) != 2 {
|
||||
t.Errorf("want 2 paths sent, got %d", len(fc.sentPaths))
|
||||
}
|
||||
if fc.sentPeer.Info.Alias != "Target" {
|
||||
t.Errorf("sent to %q, want Target", fc.sentPeer.Info.Alias)
|
||||
}
|
||||
if got.screen != screenTransfers {
|
||||
t.Errorf("after quick-send want screenTransfers, got %v", got.screen)
|
||||
}
|
||||
if got.quickSend {
|
||||
t.Error("quickSend should reset after sending")
|
||||
}
|
||||
if got.sendPeer == nil || got.sendPaths == nil {
|
||||
t.Error("sendPeer/sendPaths should be retained for a possible PIN retry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickSendAutoQuitOnlyWhenAllDone(t *testing.T) {
|
||||
m := New(config.Config{}, nil, WithStagedFiles([]string{"/tmp/a.txt"}))
|
||||
if !m.quitAfterSend {
|
||||
t.Fatal("WithStagedFiles should arm quitAfterSend")
|
||||
}
|
||||
|
||||
// Active transfer: do not schedule a quit.
|
||||
m.applyTransfer(transfer.Event{ID: "1", Dir: transfer.Outgoing, Kind: transfer.Start, Total: 10, Received: 0})
|
||||
if cmd := m.maybeScheduleQuit(); cmd != nil {
|
||||
t.Fatal("should not schedule quit while a transfer is active")
|
||||
}
|
||||
|
||||
// A second file starts; first finishes — still one active, no quit.
|
||||
m.applyTransfer(transfer.Event{ID: "2", Dir: transfer.Outgoing, Kind: transfer.Start, Total: 10})
|
||||
m.applyTransfer(transfer.Event{ID: "1", Kind: transfer.FileDone})
|
||||
if m.allSendsDoneOK() {
|
||||
t.Fatal("not all sends are done yet (file 2 active)")
|
||||
}
|
||||
|
||||
// Both done → schedules a quit.
|
||||
m.applyTransfer(transfer.Event{ID: "2", Kind: transfer.FileDone})
|
||||
if cmd := m.maybeScheduleQuit(); cmd == nil {
|
||||
t.Fatal("should schedule quit once all sends are done")
|
||||
}
|
||||
|
||||
// An errored transfer must NOT auto-quit (leave the box open).
|
||||
em := New(config.Config{}, nil, WithStagedFiles([]string{"/tmp/x"}))
|
||||
em.applyTransfer(transfer.Event{ID: "9", Dir: transfer.Outgoing, Kind: transfer.Error})
|
||||
if em.allSendsDoneOK() {
|
||||
t.Error("errored transfer should not count as done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirsOnlyFiltersToFolders(t *testing.T) {
|
||||
m := New(config.Config{}, nil)
|
||||
m.fzfEntries = []fzfEntry{
|
||||
{path: "/r/Photos", rel: "Photos", dir: true},
|
||||
{path: "/r/Photos/a.jpg", rel: "Photos/a.jpg"},
|
||||
{path: "/r/notes.txt", rel: "notes.txt"},
|
||||
}
|
||||
m.fzfRels = []string{"Photos", "Photos/a.jpg", "notes.txt"}
|
||||
|
||||
(&m).recomputeMatches()
|
||||
if len(m.fzfMatches) != 3 {
|
||||
t.Fatalf("default: want all 3, got %d", len(m.fzfMatches))
|
||||
}
|
||||
|
||||
m.fzfDirsOnly = true
|
||||
(&m).recomputeMatches()
|
||||
if len(m.fzfMatches) != 1 {
|
||||
t.Fatalf("dirs-only: want 1 folder, got %d", len(m.fzfMatches))
|
||||
}
|
||||
if got := m.fzfEntries[m.fzfMatches[0]].rel; got != "Photos" {
|
||||
t.Errorf("dirs-only kept %q, want Photos", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleStageCursorAddsAndRemoves(t *testing.T) {
|
||||
m := seedFinder("a.txt", "b.txt")
|
||||
(&m).recomputeMatches()
|
||||
|
||||
m.fzfCursor = 1
|
||||
(&m).toggleStageCursor()
|
||||
if !contains(m.staged, "b.txt") {
|
||||
t.Fatal("toggle should stage b.txt")
|
||||
}
|
||||
(&m).toggleStageCursor()
|
||||
if contains(m.staged, "b.txt") {
|
||||
t.Fatal("second toggle should unstage b.txt")
|
||||
}
|
||||
}
|
||||
320
internal/tui/view_send.go
Normal file
320
internal/tui/view_send.go
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
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")
|
||||
}
|
||||
109
nautilus/omarchy-send.py
Normal file
109
nautilus/omarchy-send.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Nautilus right-click integration for Omarchy-Send.
|
||||
|
||||
Adds a "Send via Omarchy-Send" entry to the file/folder context menu. Because
|
||||
omarchy-send is a terminal app, the entry opens it in a floating presentation
|
||||
terminal (the same wrapper Omarchy's Transcode entry uses), passing the selected
|
||||
paths so they arrive pre-staged on the device list — pick a device and send.
|
||||
|
||||
Both files and directories are supported (omarchy-send expands a folder on send).
|
||||
|
||||
Note on resolution: omarchy-send installs to ~/.local/bin, which is often NOT on
|
||||
the PATH the Nautilus process (and the terminal it spawns) inherits from the
|
||||
graphical session. So we resolve it to an absolute path — PATH first, then the
|
||||
default install locations under $HOME — and invoke it by that absolute path.
|
||||
|
||||
Installed to ~/.local/share/nautilus-python/extensions/ by omarchy-send's
|
||||
install.sh on Omarchy desktops.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
|
||||
from gi import require_version
|
||||
|
||||
require_version("Nautilus", "4.1")
|
||||
|
||||
from gi.repository import GObject, Gio, Nautilus
|
||||
|
||||
|
||||
def _resolve(name, fallbacks):
|
||||
"""Find an executable by PATH, then by a list of absolute fallback paths."""
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
return found
|
||||
for path in fallbacks:
|
||||
if path and os.path.isfile(path) and os.access(path, os.X_OK):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _binary():
|
||||
home = os.path.expanduser("~")
|
||||
fallbacks = []
|
||||
bin_dir = os.environ.get("BIN_DIR")
|
||||
if bin_dir:
|
||||
fallbacks.append(os.path.join(bin_dir, "omarchy-send"))
|
||||
fallbacks.append(os.path.join(home, ".local", "bin", "omarchy-send"))
|
||||
fallbacks.append(os.path.join(home, "bin", "omarchy-send"))
|
||||
return _resolve("omarchy-send", fallbacks)
|
||||
|
||||
|
||||
def _wrapper():
|
||||
home = os.path.expanduser("~")
|
||||
fallbacks = [
|
||||
os.path.join(home, ".local", "share", "omarchy", "bin",
|
||||
"omarchy-launch-floating-terminal-with-presentation"),
|
||||
]
|
||||
return _resolve("omarchy-launch-floating-terminal-with-presentation", fallbacks)
|
||||
|
||||
|
||||
class OmarchySendAction(GObject.GObject, Nautilus.MenuProvider):
|
||||
def _launch(self, paths):
|
||||
wrapper = _wrapper()
|
||||
binary = _binary()
|
||||
if not wrapper or not binary:
|
||||
return
|
||||
# Use the absolute binary path: the wrapper's `bash -c` may not have
|
||||
# ~/.local/bin on its PATH either.
|
||||
cmd = shlex.join([binary, *paths])
|
||||
Gio.Subprocess.new([wrapper, cmd], Gio.SubprocessFlags.NONE)
|
||||
|
||||
def _selected_paths(self, files):
|
||||
paths = []
|
||||
seen = set()
|
||||
for file in files:
|
||||
location = file.get_location()
|
||||
if not location:
|
||||
continue
|
||||
path = location.get_path()
|
||||
if path and path not in seen:
|
||||
seen.add(path)
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
def _make_item(self, paths):
|
||||
label = (
|
||||
"Send via Omarchy-Send"
|
||||
if len(paths) == 1
|
||||
else f"Send {len(paths)} items via Omarchy-Send"
|
||||
)
|
||||
item = Nautilus.MenuItem(
|
||||
name="OmarchySendNautilus::send",
|
||||
label=label,
|
||||
icon="omarchy-send",
|
||||
)
|
||||
item.connect("activate", self._on_activate, paths)
|
||||
return item
|
||||
|
||||
def _on_activate(self, _menu, paths):
|
||||
self._launch(paths)
|
||||
|
||||
def get_file_items(self, *args):
|
||||
files = args[0] if len(args) == 1 else args[1]
|
||||
if not _wrapper() or not _binary():
|
||||
return []
|
||||
paths = self._selected_paths(files)
|
||||
if not paths:
|
||||
return []
|
||||
return [self._make_item(paths)]
|
||||
Loading…
Add table
Reference in a new issue