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:
28allday 2026-06-01 14:45:57 +01:00
parent e66662feb9
commit cfa719e2b4
10 changed files with 1073 additions and 107 deletions

2
.gitignore vendored
View file

@ -1,3 +1,5 @@
/omarchy-send
/dist/omarchy-send-linux-*
*.tmp
__pycache__/
*.pyc

View file

@ -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`

View file

@ -10,6 +10,7 @@ import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
@ -40,9 +41,25 @@ 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) }
// 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.
@ -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
View file

@ -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
View file

@ -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=

View file

@ -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

View file

@ -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
View 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
View 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
View 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)]