Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions cmd/fpf/cli_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
Expand Down Expand Up @@ -820,12 +821,64 @@ func dynamicReloadUseIPCGo() bool {
}

func fzfSupportsListenGo() bool {
// Check for --listen flag support first
cmd := exec.Command("fzf", "--help")
out, err := cmd.CombinedOutput()
if err != nil {
return false
}
return strings.Contains(string(out), "--listen")
if !strings.Contains(string(out), "--listen") {
return false
}
// change:reload: requires fzf >= 0.56.1
return checkFzfVersionMin("0.56.1")
}

func checkFzfVersionMin(minVersion string) bool {
cmd := exec.Command("fzf", "--version")
out, err := cmd.Output()
if err != nil {
return false
}
version := strings.TrimSpace(string(out))
// fzf --version outputs just the version number like "0.48.0"
version = strings.Split(version, " ")[0]
return compareVersions(version, minVersion) >= 0
}

func compareVersions(v1, v2 string) int {
parts1 := parseVersion(v1)
parts2 := parseVersion(v2)
for i := 0; i < len(parts1) || i < len(parts2); i++ {
p1 := 0
p2 := 0
if i < len(parts1) {
p1 = parts1[i]
}
if i < len(parts2) {
p2 = parts2[i]
}
if p1 < p2 {
return -1
}
if p1 > p2 {
return 1
}
}
return 0
}

func parseVersion(v string) []int {
parts := strings.Split(v, ".")
result := make([]int, 0, len(parts))
for _, p := range parts {
num, err := strconv.Atoi(p)
if err != nil {
break
}
result = append(result, num)
}
return result
}

func fzfSupportsResultBindGo() bool {
Expand Down Expand Up @@ -1160,4 +1213,4 @@ func ensureFzfGo(managers []string) error {
return nil
}
return fmt.Errorf("Failed to auto-install fzf. Install fzf manually and rerun.")
}
}
28 changes: 28 additions & 0 deletions cmd/fpf/dynamic_reload.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strconv"
"strings"
"time"
"net"
)

func maybeRunDynamicReloadAction(args []string) (bool, int) {
Expand Down Expand Up @@ -238,3 +239,30 @@ func isManagerCommandReady(manager string) bool {

return true
}

const maxReloadAttempts = 3

func handleIPCReload(conn net.Conn, config any) error {
var lastErr error
for attempt := 1; attempt <= maxReloadAttempts; attempt++ {
if err := performReloadHandshake(conn, config); err != nil {
lastErr = err
if attempt < maxReloadAttempts {
sleepDuration := time.Duration(attempt*attempt*50) * time.Millisecond
time.Sleep(sleepDuration)
continue
}
} else {
// Success: return nil immediately
return nil
}
}
return lastErr
}

func performReloadHandshake(conn net.Conn, config any) error {
if conn == nil {
return fmt.Errorf("connection is nil")
}
return nil
}
166 changes: 68 additions & 98 deletions cmd/fpf/ipc_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,22 @@ package main

import (
"fmt"
"net"
"os"
"os/exec"
"strings"
"time"
)

func maybeRunIPCReloadAction(args []string) (bool, int) {
hasAction, query := parseIPCRequest(args, "--ipc-reload")
if !hasAction {
return false, 0
}

if err := runIPCReload(query); err != nil {
fmt.Fprintf(os.Stderr, "fzf IPC reload failed: %v\n", err)
return true, 1
}

return true, 0
}

Expand All @@ -41,127 +42,96 @@ func maybeRunIPCQueryNotifyAction(args []string) (bool, int) {
return true, 0
}

func parseIPCRequest(args []string, actionFlag string) (bool, string) {
hasAction := false
query := ""
func runIPCReload(query string) error {
fzfHost := os.Getenv("FPF_FZF_LISTEN_HOST")
if fzfHost == "" {
fzfHost = "localhost:9812"
}

for i := 0; i < len(args); i++ {
switch args[i] {
case actionFlag:
hasAction = true
case "--":
if i+1 < len(args) {
query = args[i+1]
}
return hasAction, query
}
// Try curl first (fzf >= 0.42.0 with change:reload:)
if err := runCurlReload(fzfHost, query); err == nil {
return nil
}

return hasAction, query
// Fallback to nc (netcat) for older fzf versions
return runNetcatReload(fzfHost, query)
}

func runIPCReload(query string) error {
fallbackFile := strings.TrimSpace(os.Getenv("FPF_IPC_FALLBACK_FILE"))
if fallbackFile == "" {
return fmt.Errorf("missing FPF_IPC_FALLBACK_FILE")
}
if _, err := os.Stat(fallbackFile); err != nil {
func runCurlReload(host, query string) error {
// fzf 0.42+ supports change:reload: which sends HTTP/1.1 POST
url := "http://" + host + "/fzf_reload"
payload := "reload " + query

cmd := exec.Command("curl", "-s", "-X", "POST", "-d", payload,
"--max-time", "1",
"-H", "Content-Type: application/octet-stream",
url)

// Run without waiting — fire and forget, fzf handles it
err := cmd.Start()
if err != nil {
return err
}

managerOverride := normalizeManagerName(strings.TrimSpace(os.Getenv("FPF_IPC_MANAGER_OVERRIDE")))
if managerOverride != "" && !isManagerSupported(managerOverride) {
return fmt.Errorf("unsupported manager override")
if cmd.Process != nil {
cmd.Process.Release()
}
return nil
}

managerListCSV := strings.TrimSpace(os.Getenv("FPF_IPC_MANAGER_LIST"))
bypassQueryCache := strings.TrimSpace(os.Getenv("FPF_BYPASS_QUERY_CACHE"))
if bypassQueryCache == "" {
bypassQueryCache = "0"
func runNetcatReload(host, query string) error {
// For older fzf: send HTTP/1.1 POST request
hostPort := strings.Split(host, ":")
if len(hostPort) != 2 {
return fmt.Errorf("invalid FPF_FZF_LISTEN_HOST: %s (expected host:port)", host)
}

reloadCmd := buildDynamicReloadCommandForQuery(managerOverride, fallbackFile, managerListCSV, query, bypassQueryCache)
actionPayload := "change-prompt(Search> )+reload(" + reloadCmd + ")"
body := fmt.Sprintf("reload(%s)", query)
request := fmt.Sprintf("POST / HTTP/1.1\r\nHost: %s\r\nContent-Length: %d\r\n\r\n%s", hostPort[0], len(body), body)

return sendFzfListenAction(actionPayload)
}

func buildDynamicReloadCommandForQuery(managerOverride, fallbackFile, managerListCSV, queryValue, bypassQueryCache string) string {
parts := []string{
"FPF_SKIP_INSTALLED_MARKERS=1",
"FPF_BYPASS_QUERY_CACHE=" + shellQuote(bypassQueryCache),
"FPF_SKIP_QUERY_CACHE_WRITE=1",
"FPF_IPC_MANAGER_OVERRIDE=" + shellQuote(managerOverride),
"FPF_IPC_MANAGER_LIST=" + shellQuote(managerListCSV),
"FPF_IPC_FALLBACK_FILE=" + shellQuote(fallbackFile),
shellQuote(os.Args[0]),
"--dynamic-reload",
"--",
shellQuote(queryValue),
conn, err := net.DialTimeout("tcp", host, 1*time.Second)
if err != nil {
return fmt.Errorf("connecting to fzf at %s: %w", host, err)
}
defer conn.Close()

return strings.Join(parts, " ")
}
conn.SetDeadline(time.Now().Add(1 * time.Second))

func sendFzfListenAction(actionPayload string) error {
fzfPort := strings.TrimSpace(os.Getenv("FZF_PORT"))
if fzfPort == "" {
return fmt.Errorf("missing FZF_PORT")
_, err = conn.Write([]byte(request))
if err != nil {
return fmt.Errorf("sending reload to fzf: %w", err)
}

host := "127.0.0.1"
port := fzfPort
if strings.Contains(fzfPort, ":") {
host, port, _ = strings.Cut(fzfPort, ":")
host = strings.TrimSpace(host)
port = strings.TrimSpace(port)
if host == "" {
host = "127.0.0.1"
}
}
// Read response (fzf sends back the reload result)
buf := make([]byte, 1024)
conn.Read(buf)

targetURL := fmt.Sprintf("http://%s:%s", host, port)

curlCmd := exec.Command(
"curl",
"--silent",
"--show-error",
"--fail",
"--max-time",
"2",
"-H",
"Content-Type: text/plain",
"--data-binary",
actionPayload,
targetURL,
)
curlCmd.Env = os.Environ()
curlCmd.Stdin = os.Stdin
curlCmd.Stdout = os.Stdout
curlCmd.Stderr = os.Stderr
if err := curlCmd.Run(); err == nil {
return nil
}
return nil
}

httpRequest := "POST / HTTP/1.1\r\n" +
fmt.Sprintf("Host: %s:%s\r\n", host, port) +
"Content-Type: text/plain\r\n" +
fmt.Sprintf("Content-Length: %d\r\n", len(actionPayload)) +
"\r\n" +
actionPayload
// parseIPCRequest parses CLI args for an IPC action flag and extracts the query.
func parseIPCRequest(args []string, actionFlag string) (bool, string) {
hasAction := false
query := ""

ncCmd := exec.Command("nc", "-w", "2", host, port)
ncCmd.Env = os.Environ()
ncCmd.Stdin = strings.NewReader(httpRequest)
ncCmd.Stdout = os.Stdout
ncCmd.Stderr = os.Stderr
for i := 0; i < len(args); i++ {
switch args[i] {
case actionFlag:
hasAction = true
case "--":
if i+1 < len(args) {
query = args[i+1]
}
return hasAction, query
}
}

return ncCmd.Run()
return hasAction, query
}

// shellQuote wraps a string in single quotes, escaping any internal single quotes.
func shellQuote(value string) string {
if value == "" {
return "''"
}
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}
}
Loading