diff --git a/.gitignore b/.gitignore index 8331d53..c5af4aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /omarchy-send /dist/omarchy-send-linux-* *.tmp +__pycache__/ +*.pyc diff --git a/README.md b/README.md index f4dd7ef..e2725f7 100644 --- a/README.md +++ b/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 `, 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` diff --git a/cmd/omarchy-send/main.go b/cmd/omarchy-send/main.go index 654ca2f..18e89e1 100644 --- a/cmd/omarchy-send/main.go +++ b/cmd/omarchy-send/main.go @@ -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 `). 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 diff --git a/go.mod b/go.mod index e844e70..a135ad2 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 79e2c83..e247718 100644 --- a/go.sum +++ b/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= diff --git a/install.sh b/install.sh index b9952a8..ba8519c 100755 --- a/install.sh +++ b/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 diff --git a/internal/tui/model.go b/internal/tui/model.go index b8e5705..f3c157c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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: diff --git a/internal/tui/send_test.go b/internal/tui/send_test.go new file mode 100644 index 0000000..4e63186 --- /dev/null +++ b/internal/tui/send_test.go @@ -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") + } +} diff --git a/internal/tui/view_send.go b/internal/tui/view_send.go new file mode 100644 index 0000000..bc3df1b --- /dev/null +++ b/internal/tui/view_send.go @@ -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") +} diff --git a/nautilus/omarchy-send.py b/nautilus/omarchy-send.py new file mode 100644 index 0000000..4ca587a --- /dev/null +++ b/nautilus/omarchy-send.py @@ -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)]