diff --git a/cmd/fpf/cli_runtime.go b/cmd/fpf/cli_runtime.go index 0c01410..d30c892 100644 --- a/cmd/fpf/cli_runtime.go +++ b/cmd/fpf/cli_runtime.go @@ -9,6 +9,7 @@ import ( "path/filepath" "runtime" "sort" + "strconv" "strings" "time" ) @@ -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 { @@ -1160,4 +1213,4 @@ func ensureFzfGo(managers []string) error { return nil } return fmt.Errorf("Failed to auto-install fzf. Install fzf manually and rerun.") -} +} \ No newline at end of file diff --git a/cmd/fpf/dynamic_reload.go b/cmd/fpf/dynamic_reload.go index b3a49ea..e35f734 100644 --- a/cmd/fpf/dynamic_reload.go +++ b/cmd/fpf/dynamic_reload.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" "time" + "net" ) func maybeRunDynamicReloadAction(args []string) (bool, int) { @@ -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 +} \ No newline at end of file diff --git a/cmd/fpf/ipc_actions.go b/cmd/fpf/ipc_actions.go index e638aba..4c4e47e 100644 --- a/cmd/fpf/ipc_actions.go +++ b/cmd/fpf/ipc_actions.go @@ -2,9 +2,11 @@ package main import ( "fmt" + "net" "os" "os/exec" "strings" + "time" ) func maybeRunIPCReloadAction(args []string) (bool, int) { @@ -12,11 +14,10 @@ func maybeRunIPCReloadAction(args []string) (bool, int) { 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 } @@ -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, "'", "'\\''") + "'" -} +} \ No newline at end of file